fix(release): refuse an empty or multi-document latest.json, test the Publish step #464
9 changed files with 206 additions and 30 deletions
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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` |
|
||||||
|
|
|
||||||
|
|
@ -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`).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 (.platforms | keys) == ["linux-x86_64-deb", "linux-x86_64-rpm", "windows-x86_64"]
|
and (.[0]
|
||||||
and all(.platforms[]; (.signature | type) == "string" and (.signature | length) > 0)
|
| .version == $version
|
||||||
and (.platforms["linux-x86_64-deb"].url | endswith(".deb"))
|
and (.platforms | keys) == ["linux-x86_64-deb", "linux-x86_64-rpm", "windows-x86_64"]
|
||||||
and (.platforms["linux-x86_64-rpm"].url | endswith(".rpm"))
|
and all(.platforms[]; (.signature | type) == "string" and (.signature | length) > 0)
|
||||||
and (.platforms["windows-x86_64"].url | endswith(".exe"))
|
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