From c859405debd5e39e91b919faed83c1733de42709 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sun, 18 Oct 2026 13:00:40 -0400 Subject: [PATCH] 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 --- .claude/skills/release/SKILL.md | 3 +- .forgejo/workflows/check-frontend.yml | 7 +- .forgejo/workflows/release.yml | 18 +-- CLAUDE.md | 2 +- REVIEW.md | 6 +- docs/architecture.md | 4 +- docs/qa-update-cycle.md | 4 +- scripts/build-latest-json.test.mjs | 170 +++++++++++++++++++++++++- scripts/check-latest-json.jq | 22 ++-- 9 files changed, 206 insertions(+), 30 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 2a617a9..b9f325d 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -50,7 +50,7 @@ updated: 2026-10-11 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 trois clés** (#320) : `linux-x86_64-deb`, `linux-x86_64-rpm` et `windows-x86_64`, sans clé générique `linux-x86_64` depuis le 2026-10-11 (`docs/qa-update-cycle.md`). Pour chacune : l'extension de l'URL (`.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é. 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. + **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, le filtre `scripts/check-latest-json.jq` et le texte de l'étape « Publish » (avec un `curl` factice) passent par les tests de `check-frontend` à chaque PR ; l'appel du script par l'étape « Generate latest.json », le délimiteur aléatoire des notes et les vrais appels au registre 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` : sans paramètre `page`, la route rend toute la liste, la plus récente en tête, et ignore `limit` (avec `page`, elle pagine par 30 ou par `limit`, mesuré le 2026-10-12) : ```bash @@ -91,3 +91,4 @@ updated: 2026-10-11 - 2026-10-10 — Étape 9 (post-CI) : `verify-release.sh` exige les quatre clés de `latest.json` (`linux-x86_64`, `linux-x86_64-deb`, `linux-x86_64-rpm`, `windows-x86_64`), l'extension de chaque URL et le `file:` minisign de chaque signature, et sort en 1 sur tout écart (verdict en dernière ligne) ; lire l'étape « Generate latest.json » du journal. Source : #320 (milestone `planned-2026-10-10-release-deps`). - 2026-10-11 — Étape 9 (post-CI) : `latest.json` à trois clés, la clé générique `linux-x86_64` étant retirée (décision de Max sur #320 : ses derniers lecteurs, AppImage v0.4.3 à v0.4.5 et binaires hors paquet, ne savaient pas installer le `.deb` qu'elle servait). Le filtre d'avant le `DELETE` vit dans `scripts/check-latest-json.jq`, que les tests de `check-frontend` exercent à chaque PR, avec la comparaison des trois listes de clés. Source : revue de la PR #416. - 2026-10-11 — Étape 2 : rejouer le garde `windows-sys` de `check-rust.yml` (ADR 0023) juste après l'édition de `Cargo.lock`, puisque le commit de release n'a pas de PR et que `check-rust` ne le verrait qu'au lundi suivant. Commande éprouvée sur le lock de la pile (exit 0) et sur une arête dérivée (exit 1). Source : revue de la PR #415. +- 2026-10-18 — Étape 9 (post-CI) : le filtre de `latest.json` est appelé avec `-s` (fichier vide ou à deux documents refusé sous jq 1.6, celle du conteneur), et le texte de l'étape « Publish » de `release.yml` est exécuté par `scripts/build-latest-json.test.mjs` ; `check-frontend` ne saute plus les PR qui ne touchent que `.claude/**` ou `release.yml`. Source : #419 (suivis de la revue de la PR #418). diff --git a/.forgejo/workflows/check-frontend.yml b/.forgejo/workflows/check-frontend.yml index d95bd74..1abb253 100644 --- a/.forgejo/workflows/check-frontend.yml +++ b/.forgejo/workflows/check-frontend.yml @@ -7,6 +7,11 @@ name: PR Check — Frontend # running it is not. A root-level config file added later would drop out of an # allowlist without anyone noticing; here it fails safe. # +# .claude/** and release.yml are not ignored (#419): scripts/build-latest-json.test.mjs +# reads .claude/skills/release/scripts/verify-release.sh and runs the text of +# release.yml's "Publish latest.json to package registry" step, so a PR that +# touches only one of them must still run it. +# # No `branches:` filter — see check-rust.yml. on: @@ -18,10 +23,8 @@ on: - 'reports/**' - 'tasks/**' - '.github/**' - - '.claude/**' - '.forgejo/workflows/check-rust.yml' - '.forgejo/workflows/audit.yml' - - '.forgejo/workflows/release.yml' - '*.md' - 'LICENSE' diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 7d14160..79eb542 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -101,9 +101,10 @@ jobs: env: CHANGELOG_NOTES: ${{ steps.changelog.outputs.notes }} run: | - # One key per Linux package, a .deb fallback and the NSIS installer; - # fails unless it finds exactly one .deb, .rpm and -setup.exe, each - # with a non-empty .sig (#320). Notes reach jq as a file, as data. + # Three keys: one per Linux package (.deb, .rpm) and the NSIS + # installer, no generic linux-x86_64 key (#320); fails unless it finds + # exactly one .deb, .rpm and -setup.exe, each with a non-empty .sig. + # Notes reach jq as a file, as data. # Called as scripts/build-latest-json.test.mjs calls it. printf '%s' "$CHANGELOG_NOTES" > /tmp/release-notes.md bash -e -o pipefail scripts/build-latest-json.sh \ @@ -191,13 +192,16 @@ jobs: env: FORGEJO_TOKEN: ${{ secrets.PACKAGE_TOKEN }} run: | + # This text is run by scripts/build-latest-json.test.mjs, with a fake + # curl: keep it self-contained, no workflow expressions (dollar, + # double brace) in run:, pass values through env: (#419). + # # 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. - # 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}" \ + # The filter lives in scripts/check-latest-json.jq; -s makes it + # refuse an empty file or one with two documents (#419). + if ! jq -e -s --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 diff --git a/CLAUDE.md b/CLAUDE.md index 6f0defa..e3b31f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,7 +165,7 @@ Pour maintenir l'éligibilité aux crédits d'impôt R&D (RS&DE fédéral + CRIC Workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **capacité 1** — les jobs se suivent, ils ne tournent pas en parallèle. - **`check-rust.yml`** — déclenché sur les PR touchant `src-tauri/**` ou `.cargo/**`, plus chaque lundi à 06:30 UTC sur `main` et par `workflow_dispatch`. Lance `cargo check`, une vérification bloquante que les advisories acceptées restent non atteignables, une autre que les sept arêtes `windows-sys` à borne large du lock restent sur `0.61.2` ([ADR 0023](docs/adr/0023-windows-sys-pinning.md)), `cargo test` et un `cargo audit` informatif, avec le cache `Swatinem/rust-cache`. Doit être vert avant tout merge. Un `cargo update` qui re-résout ces arêtes (`-p hyper` et `-p h2` l'ont fait) fait rougir le garde `windows-sys` : remettre les arêtes à la main selon l'ADR, ne jamais modifier la liste gardée pour le faire passer. -- **`check-frontend.yml`** — déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc ou du markdown. Lance `npm run build` (tsc + vite) et `npm test` (vitest). Doit être vert avant tout merge. **Aucune étape `npm audit`** : les advisories npm ne sont pas un gate de CI. `npm audit` à 0 au 2026-10-10 ([#358](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/358)), un constat daté : aucun job ne le fait échouer, mais `npm ci` en imprime le compte à chaque run de `check-frontend`, et le Défenseur relève les advisories de son côté (#358 en vient). +- **`check-frontend.yml`** — déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc, du markdown, `check-rust.yml` ou `audit.yml` ; `.claude/**` et `release.yml` le déclenchent, car `scripts/build-latest-json.test.mjs` lit `verify-release.sh` et exécute l'étape « Publish » de `release.yml` (#419). Lance `npm run build` (tsc + vite) et `npm test` (vitest). Doit être vert avant tout merge. **Aucune étape `npm audit`** : les advisories npm ne sont pas un gate de CI. `npm audit` à 0 au 2026-10-10 ([#358](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/358)), un constat daté : aucun job ne le fait échouer, mais `npm ci` en imprime le compte à chaque run de `check-frontend`, et le Défenseur relève les advisories de son côté (#358 en vient). - **`audit.yml`** — audit RustSec quotidien (06:00 UTC) + `workflow_dispatch`, pour couvrir les avis de sécurité entre deux PR Rust. Échec bloquant. **Un run vert signifie « zéro advisory hors de la liste acceptée »**, pas « zéro advisory » : cette liste est dans `.cargo/audit.toml` (preuve de non-atteignabilité et condition de retrait par entrée), encadrée par l'[ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md). Ne jamais y ajouter une advisory atteignable ni élargir une entrée à un crate entier. - **`release.yml`** — déclenché par les tags `v*`. Build Windows (NSIS `.exe`) + Linux (`.deb`, `.rpm`), signe les binaires et publie le JSON d'updater pour les mises à jour automatiques. diff --git a/REVIEW.md b/REVIEW.md index dcc76a0..7e44b94 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -133,7 +133,7 @@ Chaque règle cite sa source. Elles valent quel que soit le niveau : une PR norm - Aucun workflow `check-*` ne filtre sur `branches:` : `branches: [main]` avait laissé sans CI quatre des cinq PR empilées de la milestone feature-gating. `check-frontend.yml` exclut par `paths-ignore` pour qu'un fichier nouveau déclenche la CI au lieu de la sauter. — `CLAUDE.md` « CI/CD », en-têtes de `check-rust.yml` et `check-frontend.yml` - `audit.yml` est le seul canal des avis RustSec : quotidien et bloquant. L'étape `cargo audit` de `check-rust` est non bloquante par choix. — `CLAUDE.md` « CI/CD » - Une garde affiche chacune de ses vérifications, y compris celles qui passent, et lit un canari : une garde muette ne prouve rien. — [ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md) (règle 3), [ADR 0023](docs/adr/0023-windows-sys-pinning.md) -- Une PR qui ne touche que `audit.yml` ou `release.yml` n'a aucune CI : les `paths` de `check-rust.yml` ne les listent pas, et le `paths-ignore` de `check-frontend.yml` les exclut. La revue rejoue elle-même ce qui peut l'être. — `CLAUDE.md` « CI/CD », `check-rust.yml`, `check-frontend.yml` +- Une PR qui ne touche que `audit.yml` n'a aucune CI : les `paths` de `check-rust.yml` ne le listent pas, et le `paths-ignore` de `check-frontend.yml` l'exclut. Une PR qui ne touche que `release.yml` lance `check-frontend`, dont un test exécute le texte de l'étape « Publish latest.json to package registry » avec un `curl` factice (#419) ; le reste de `release.yml` ne s'exerce qu'au tag. La revue rejoue elle-même ce qui peut l'être. — `CLAUDE.md` « CI/CD », `check-rust.yml`, `check-frontend.yml` - Les secrets du dépôt (clé de signature et son mot de passe, jeton du registre de paquets qui remplace `latest.json`) sont lisibles par tout workflow : un workflow ajouté, une référence à `secrets` ajoutée ou un déclencheur `pull_request_target` se lit comme du code de release. — `CLAUDE.md` « CI/CD », `release.yml` ### i18n et documentation @@ -148,11 +148,11 @@ Chaque règle cite sa source. Elles valent quel que soit le niveau : une PR norm ## Commandes de vérification -Les workflows font foi ; ce tableau les résume au 2026-10-17. Le runner a une capacité de 1 : les jobs se suivent, et chaque maillon d'une pile a sa CI. — `CLAUDE.md` « CI/CD » +Les workflows font foi ; ce tableau les résume au 2026-10-18. Le runner a une capacité de 1 : les jobs se suivent, et chaque maillon d'une pile a sa CI. — `CLAUDE.md` « CI/CD » | Workflow | Déclencheur | Commandes | |---|---|---| -| `check-frontend.yml` | PR, sauf si tous ses fichiers sont exclus : `src-tauri/**`, `.cargo/**`, `docs/**`, `reports/**`, `tasks/**`, `.github/**`, `.claude/**`, `*.md` à la racine, `LICENSE`, les trois autres workflows | `npm ci`, `npm run build` (tsc + vite), `npm test` (vitest, `scripts/build-latest-json.test.mjs` compris) | +| `check-frontend.yml` | PR, sauf si tous ses fichiers sont exclus : `src-tauri/**`, `.cargo/**`, `docs/**`, `reports/**`, `tasks/**`, `.github/**`, `*.md` à la racine, `LICENSE`, `check-rust.yml` et `audit.yml` | `npm ci`, `npm run build` (tsc + vite), `npm test` (vitest, `scripts/build-latest-json.test.mjs` compris) | | `check-rust.yml` | PR qui touche `src-tauri/**`, `.cargo/**` ou ce workflow ; lundi 06:30 UTC sur `main` ; `workflow_dispatch` | `cargo check --manifest-path src-tauri/Cargo.toml --all-targets`, garde des advisories supprimées, garde `windows-sys`, `cargo test --manifest-path src-tauri/Cargo.toml --all-targets`, `cargo audit --file src-tauri/Cargo.lock` (non bloquant) | | `audit.yml` | chaque jour à 06:00 UTC ; `workflow_dispatch` | `cargo-audit audit --file src-tauri/Cargo.lock` (bloquant) | | `release.yml` | tag `v*` | build Linux (`.deb`, `.rpm`) et Windows (cargo-xwin), signature, `latest.json` | diff --git a/docs/architecture.md b/docs/architecture.md index 3dc47df..2571560 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -662,7 +662,7 @@ Aucun `branches:` : une PR stackée sur une autre branche de feature — ce que ### `check-frontend.yml` — Vérifications frontend sur PR -Déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc ou du markdown (`paths-ignore`). Lance `npm ci`, `npm run build` (tsc + vite) et `npm test` (vitest). Installe aussi `jq`, dont a besoin `scripts/build-latest-json.test.mjs` : ce test lance le script de génération de `latest.json` de `release.yml` ([#320](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/320)). Pas de cache npm : ce workflow ne tourne que sur des PR, donc un cache ne servirait qu'aux pushes suivants d'une même PR, et `npm ci` y prend 9 s. +Déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc, du markdown, `check-rust.yml` ou `audit.yml` (`paths-ignore`). `.claude/**` et `release.yml` le déclenchent ([#419](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/419)) : `scripts/build-latest-json.test.mjs` lit la liste de clés de `verify-release.sh` et exécute le texte de l'étape « Publish latest.json to package registry ». Lance `npm ci`, `npm run build` (tsc + vite) et `npm test` (vitest). Installe aussi `jq`, dont a besoin `scripts/build-latest-json.test.mjs` : ce test lance le script de génération de `latest.json` de `release.yml` ([#320](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/320)). Pas de cache npm : ce workflow ne tourne que sur des PR, donc un cache ne servirait qu'aux pushes suivants d'une même PR, et `npm ci` y prend 9 s. **Aucune étape `npm audit`** — contrairement au Rust, les advisories npm ne sont donc pas un gate de CI. `npm audit` est à 0 au 2026-10-10 ([#358](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/358)), après un rafraîchissement du lock dans les bornes de `package.json`. Ce 0 est un constat daté, pas un état garanti : aucun job ne le fait échouer, mais l'étape `npm ci` de `check-frontend` audite à chaque run et en imprime le compte (`found 0 vulnerabilities`, ou le nombre trouvé), et le Défenseur relève les advisories de son côté (#358 en vient). `npm audit` n'a pas de mécanisme d'exclusion natif, il n'y a donc pas d'équivalent du `.cargo/audit.toml` côté npm. @@ -685,7 +685,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) et `windows-x86_64` (l'installeur NSIS). Pas de clé générique `linux-x86_64` depuis le 2026-10-11 : elle servait le `.deb` à des installations qui ne savaient pas l'installer (anciennes AppImage, binaire hors paquet), qui reçoivent désormais `TargetsNotFound` à la vérification (raisons et lecteurs dans `docs/qa-update-cycle.md`). 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, le filtre `scripts/check-latest-json.jq` (version égale au tag, exactement les trois 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. +- 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 trois clés, signatures non vides, extension de chaque URL) arrête le job si le fichier ne le passe pas : le registre reste intact. Il est appelé avec `-s` et n'accepte qu'un seul document, si bien qu'un fichier vide ou à deux documents est refusé sous jq 1.6 comme sous 1.7 ([#419](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/419)). Le test du script applique ce même filtre à chaque PR, compare sa liste de clés à celles du script et de `verify-release.sh`, et exécute le texte de l'étape « Publish » avec un `curl` factice (`DELETE` puis `PUT` sur un manifeste valide, aucun appel sur un manifeste refusé), 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/docs/qa-update-cycle.md b/docs/qa-update-cycle.md index 72686f7..37f5b5b 100644 --- a/docs/qa-update-cycle.md +++ b/docs/qa-update-cycle.md @@ -93,7 +93,7 @@ Les installations `.rpm` existantes n'ont rien à réinstaller : le binaire du ` **Pas de clé générique `linux-x86_64`** (retirée le 2026-10-11, décision de Max sur #320). Toutes les versions qui embarquent l'updater (v0.2.1 et suivantes) verrouillent `tauri-plugin-updater` 2.10.0 et `tauri-utils` 2.8.2, et leurs paquets portent le marqueur de type (vérifié dans le `.deb` et le `.rpm` de la v0.17.1) : une installation `.deb` ou `.rpm` trouve sa clé avant de chercher `linux-x86_64`. Cette clé servait le `.deb`, et ses derniers lecteurs ne savaient pas l'installer : - les AppImage des v0.4.3 à v0.4.5, publiées sur Forgejo pour la v0.4.3 et sur le miroir GitHub pour les v0.4.3 à v0.4.5. Leur updater lit le registre et cherche `linux-x86_64-appimage`, absente, puis `linux-x86_64`. La v0.4.2 vise `…/releases/latest/download/latest.json`, qui répond 404 sur cette instance Forgejo, et les versions antérieures lisent GitHub ; -- un binaire lancé hors de son paquet, sans marqueur, qui ne cherche que `linux-x86_64`. +- un binaire lancé hors de son paquet, sans marqueur, qui ne cherche que `linux-x86_64`. C'est le cas d'un build `npm run tauri dev` sous Linux : sa carte Mises à jour affiche l'erreur `TargetsNotFound` à chaque vérification. Elle est attendue depuis le retrait de la clé, ce n'est pas une régression (#419). Avec la clé, l'updater les traitait en AppImage (`install_appimage`) et écrivait les octets du `.deb` à la place du fichier lancé, ce qui cassait l'installation. Sans elle, leur vérification échoue avec `TargetsNotFound` et rien n'est installé : la carte Mises à jour affiche une erreur. La voie, pour ces installations, est de télécharger le `.deb` ou le `.rpm` de la dernière release. @@ -105,7 +105,7 @@ L'ordre compte, et la première action ne répare pas ce qu'on croit. 1. **Republier le `latest.json` précédent** sur `generic/simpl-resultat/latest` (`DELETE` puis `PUT`, avec le `PACKAGE_TOKEN` qu'utilise `release.yml`). Le corps est récupérable depuis les assets de la release précédente — sa copie dans le registre a été détruite par le `DELETE` que `release.yml` fait avant chaque envoi. - **Les deux verbes ne visent pas le même préfixe d'API**, et `release.yml:217-219` porte un commentaire explicite là-dessus — le piège est déjà tombé une fois : + **Les deux verbes ne visent pas le même préfixe d'API**, et l'étape « Publish latest.json to package registry » de `release.yml` porte un commentaire explicite là-dessus — le piège est déjà tombé une fois : ``` DELETE {serveur}/api/v1/packages/{owner}/generic/simpl-resultat/latest diff --git a/scripts/build-latest-json.test.mjs b/scripts/build-latest-json.test.mjs index c3e7451..f35c425 100644 --- a/scripts/build-latest-json.test.mjs +++ b/scripts/build-latest-json.test.mjs @@ -18,7 +18,13 @@ * 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. + * 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; @@ -27,7 +33,7 @@ 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 { 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"; @@ -36,6 +42,7 @@ 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"; @@ -80,12 +87,13 @@ function builtManifest() { 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. +// 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", "--arg", "version", version, "-f", CHECK, file], { encoding: "utf8" }); + return spawnSync("jq", ["-e", "-s", "--arg", "version", version, "-f", CHECK, file], { encoding: "utf8" }); } beforeAll(() => { @@ -283,6 +291,21 @@ describe("check-latest-json.jq", () => { 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", () => { @@ -300,3 +323,140 @@ describe("key lists", () => { 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([]); + }); +}); diff --git a/scripts/check-latest-json.jq b/scripts/check-latest-json.jq index bccec83..bb07627 100644 --- a/scripts/check-latest-json.jq +++ b/scripts/check-latest-json.jq @@ -3,24 +3,32 @@ # 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 +# Run as: jq -e -s --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. +# -s reads the whole file as one array of documents, and the filter wants +# exactly one (#419). Without it, jq 1.6 runs the filter once per document: an +# empty file gives no output, which -e lets through with status 0, and two +# documents are judged on the last one alone. jq 1.7 refuses the empty file +# (status 4) but still passes the two documents. # # 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. +# It also runs the text of release.yml's Publish step, which calls this file. # 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-deb", "linux-x86_64-rpm", "windows-x86_64"] -and all(.platforms[]; (.signature | type) == "string" and (.signature | length) > 0) -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")) +length == 1 +and (.[0] + | .version == $version + and (.platforms | keys) == ["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-deb"].url | endswith(".deb")) + and (.platforms["linux-x86_64-rpm"].url | endswith(".rpm")) + and (.platforms["windows-x86_64"].url | endswith(".exe")))