fix(release): refuse an empty or multi-document latest.json, test the Publish step #464

Merged
maximus merged 1 commit from issue-419-latest-json-check-followups into main 2026-10-18 17:37:48 +00:00
9 changed files with 206 additions and 30 deletions

View file

@ -50,7 +50,7 @@ updated: 2026-10-11
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 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é. 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. 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) : **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 ```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-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 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-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).

View file

@ -7,6 +7,11 @@ name: PR Check — Frontend
# running it is not. A root-level config file added later would drop out of an # running it is not. A root-level config file added later would drop out of an
# allowlist without anyone noticing; here it fails safe. # 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. # No `branches:` filter — see check-rust.yml.
on: on:
@ -18,10 +23,8 @@ on:
- 'reports/**' - 'reports/**'
- 'tasks/**' - 'tasks/**'
- '.github/**' - '.github/**'
- '.claude/**'
- '.forgejo/workflows/check-rust.yml' - '.forgejo/workflows/check-rust.yml'
- '.forgejo/workflows/audit.yml' - '.forgejo/workflows/audit.yml'
- '.forgejo/workflows/release.yml'
- '*.md' - '*.md'
- 'LICENSE' - 'LICENSE'

View file

@ -101,9 +101,10 @@ jobs:
env: env:
CHANGELOG_NOTES: ${{ steps.changelog.outputs.notes }} CHANGELOG_NOTES: ${{ steps.changelog.outputs.notes }}
run: | run: |
# One key per Linux package, a .deb fallback and the NSIS installer; # Three keys: one per Linux package (.deb, .rpm) and the NSIS
# fails unless it finds exactly one .deb, .rpm and -setup.exe, each # installer, no generic linux-x86_64 key (#320); fails unless it finds
# with a non-empty .sig (#320). Notes reach jq as a file, as data. # 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. # Called as scripts/build-latest-json.test.mjs calls it.
printf '%s' "$CHANGELOG_NOTES" > /tmp/release-notes.md printf '%s' "$CHANGELOG_NOTES" > /tmp/release-notes.md
bash -e -o pipefail scripts/build-latest-json.sh \ bash -e -o pipefail scripts/build-latest-json.sh \
@ -191,13 +192,16 @@ jobs:
env: env:
FORGEJO_TOKEN: ${{ secrets.PACKAGE_TOKEN }} FORGEJO_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
run: | 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 # 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.
# The filter lives in scripts/check-latest-json.jq, which # The filter lives in scripts/check-latest-json.jq; -s makes it
# scripts/build-latest-json.test.mjs also runs on every pull # refuse an empty file or one with two documents (#419).
# request, so its key list cannot drift from the script's unseen. if ! jq -e -s --arg version "${GITHUB_REF_NAME#v}" \
if ! jq -e --arg version "${GITHUB_REF_NAME#v}" \
-f scripts/check-latest-json.jq release-assets/latest.json > /dev/null; then -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:" echo "ERROR: release-assets/latest.json failed scripts/check-latest-json.jq, registry left as is:"
cat release-assets/latest.json cat release-assets/latest.json

View file

@ -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. 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-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. - **`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. - **`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.

View file

@ -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` - 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 » - `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 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` - 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 ### 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 ## 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 | | 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) | | `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) | | `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` | | `release.yml` | tag `v*` | build Linux (`.deb`, `.rpm`) et Windows (cargo-xwin), signature, `latest.json` |

View file

@ -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 ### `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. **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 : 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) 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 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) - 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

@ -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 : **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 ; - 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. 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. 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 DELETE {serveur}/api/v1/packages/{owner}/generic/simpl-resultat/latest

View file

@ -18,7 +18,13 @@
* It also runs scripts/check-latest-json.jq, the filter release.yml applies * 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 * 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 * 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 * 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;
@ -27,7 +33,7 @@
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, 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 { 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";
@ -36,6 +42,7 @@ const HERE = dirname(fileURLToPath(import.meta.url));
const SCRIPT = join(HERE, "build-latest-json.sh"); const SCRIPT = join(HERE, "build-latest-json.sh");
const CHECK = join(HERE, "check-latest-json.jq"); const CHECK = join(HERE, "check-latest-json.jq");
const VERIFY_RELEASE = join(HERE, "..", ".claude", "skills", "release", "scripts", "verify-release.sh"); 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 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";
@ -80,12 +87,13 @@ function builtManifest() {
return JSON.parse(result.stdout); return JSON.parse(result.stdout);
} }
// Runs scripts/check-latest-json.jq as release.yml does, on a manifest written // Runs scripts/check-latest-json.jq with the options release.yml passes, on a
// to a file: an object is serialized, a string is written as is. // 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") { function check(manifest, version = "0.17.1") {
const file = join(work, "latest.json"); const file = join(work, "latest.json");
writeFileSync(file, typeof manifest === "string" ? manifest : JSON.stringify(manifest)); 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(() => { beforeAll(() => {
@ -283,6 +291,21 @@ describe("check-latest-json.jq", () => {
it("refuses a file that is not JSON", () => { it("refuses a file that is not JSON", () => {
expect(check('{"version": "0.17.1", "platforms": ').status).not.toBe(0); 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", () => { describe("key lists", () => {
@ -300,3 +323,140 @@ describe("key lists", () => {
expect(expected).toStrictEqual(written); 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([]);
});
});

View file

@ -3,24 +3,32 @@
# deleted, installed apps find no update until a valid one is uploaded, so a # 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. # 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 # Run as: jq -e -s --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 # -e turns false, null and any jq error (missing field, wrong type, invalid
# JSON) into a non-zero exit status: the check fails closed. # 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 # The key set must stay the one scripts/build-latest-json.sh writes and
# .claude/skills/release/scripts/verify-release.sh expects. # .claude/skills/release/scripts/verify-release.sh expects.
# scripts/build-latest-json.test.mjs runs this file on the script's output and # 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 # 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. # 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. # 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 # 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 # test checks it before the merge, and verify-release.sh after publication
# (the file: field of the minisign trusted comment). # (the file: field of the minisign trusted comment).
.version == $version length == 1
and (.[0]
| .version == $version
and (.platforms | keys) == ["linux-x86_64-deb", "linux-x86_64-rpm", "windows-x86_64"] 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 all(.platforms[]; (.signature | type) == "string" and (.signature | length) > 0)
and (.platforms["linux-x86_64-deb"].url | endswith(".deb")) and (.platforms["linux-x86_64-deb"].url | endswith(".deb"))
and (.platforms["linux-x86_64-rpm"].url | endswith(".rpm")) and (.platforms["linux-x86_64-rpm"].url | endswith(".rpm"))
and (.platforms["windows-x86_64"].url | endswith(".exe")) and (.platforms["windows-x86_64"].url | endswith(".exe")))