docs(skills): add /issue-license and a release verification script
- issue-license (new): issue licenses by hand through maximus-api's admin endpoint (testers, gifts, refund compensation, admin account); the admin key and the issued keys never enter the transcript. Third-party beneficiaries are named by their Logto id, never by email: the repository is public. - release step 0: when a release tightens edition gating, tester keys must be entered before the tag, since an install without a key receives that version and then nothing more. - release step 9: scripts/verify-release.sh checks the 7 assets by suffix and the content of latest.json, instead of a script rewritten each time. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
14f1d3b226
commit
11e12ab7a3
3 changed files with 139 additions and 3 deletions
78
.claude/skills/issue-license/SKILL.md
Normal file
78
.claude/skills/issue-license/SKILL.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
---
|
||||
name: issue-license
|
||||
description: Émettre à la main une ou plusieurs licences Simpl-Résultat (SR-BASE / SR-PREMIUM) via l'endpoint admin de maximus-api, sans passer par Stripe, pour tout bénéficiaire : testeur, cadeau, compensation après un remboursement, compte admin, utilisateur hors Stripe ; et les distribuer avant une release qui verrouille des fonctionnalités par édition. Use when the user says "émets une licence", "clé pour Michel", "offre une licence à X", "donne une licence Premium à X", "il me faut une clé de test", "génère une licence", "compense le remboursement de X", "les testeurs doivent garder l'accès", "issue a license", "generate license key", "gift a license", or before tagging a release that changes FEATURE_TIERS.
|
||||
user-invocable: true
|
||||
allowed-tools: Bash, Read, Write
|
||||
created: 2026-09-13
|
||||
updated: 2026-09-24
|
||||
---
|
||||
|
||||
# /issue-license — Émettre des licences à la main (testeurs, cadeaux, compensations, admin)
|
||||
|
||||
Émission via `POST /v1/licenses/generate` de maximus-api (route admin, Bearer `ADMIN_API_KEY`), hors Stripe, pour tout bénéficiaire : testeur, cadeau, compensation après un remboursement, compte admin. La clé admin et les clés émises ne transitent jamais par la conversation.
|
||||
|
||||
## Faits vérifiés (2026-09-06, code v0.14.0 → main + maximus-api prod)
|
||||
|
||||
- Validation **locale** de la clé (Ed25519, clé publique embarquée + `exp`), aucun réseau, déverrouillage immédiat sans redémarrage. La clé publique est identique entre v0.14.0 et `main` → une clé peut être entrée **avant** la mise à jour.
|
||||
- Endpoint : `POST https://api.lacompagniemaximus.com/v1/licenses/generate`, Bearer `ADMIN_API_KEY` (celle de prod vit dans Coolify → maximus-api → Environment Variables ; le `.env` local en a une autre). Payload : `product: "simpl-resultat"`, `email` (affiché dans la carte licence → vrai courriel), `edition` (`base` | `premium`), `expires_in_days` (défaut et max 3650), `machine_limit` (défaut 3, max 10). Réponse `{ key: "SR-<EDITION>-<JWT>", expires_at }`. **N'envoie aucun courriel** (seul le flux Stripe le fait) → distribution manuelle. Rate limit 5/min sur `/v1/licenses/*`.
|
||||
- Activation machine optionnelle : l'édition résout sans `activation.token` (`license_commands.rs`, état « graceful pre-activation »). Seule exception : le fetch de cours du Bilan envoie `activation.token` en Bearer à `/v1/prices` (`balance_commands.rs`) → un testeur Premium qui utilise les cours doit cliquer « Activer cette machine ». Une clé non activée marche sur n'importe quel poste ; activée, elle est liée (3 machines par défaut).
|
||||
- Un compte Logto n'est **pas** requis : `generate` prend un courriel libre. Un compte Logto sans abonnement `active` ne donne rien côté app.
|
||||
- Clé révoquée (`is_revoked = t`) : elle continue de déverrouiller l'UI hors ligne, seul le serveur (activation, cours) la refuse. Une nouvelle clé saisie écrase l'ancienne dans `license.key` du client.
|
||||
- Bénéficiaires connus (testeurs) : Max (`maxime2tremblay@protonmail.com`) et Michel (Logto `michelt` ; son courriel est dans la table `users` de Logto et dans la mémoire projet `project-tester-licenses`, pas ici). Les deux en **Premium**, 3650 j, 3 machines (décision Max 2026-09-06).
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Inventaire des licences existantes** (commande à Max en `!`, le classifier bloque les `ssh` VPS en mode auto) :
|
||||
```
|
||||
! ssh ubuntu@100.86.30.21 "docker exec zgkgc8soo8k4k44woo00ckg8 psql -U postgres -d maximus_api -t -A -F ' | ' -c \"SELECT product, edition, user_email, machine_limit, is_revoked, created_at::date, expires_at::date FROM licenses ORDER BY created_at;\""
|
||||
```
|
||||
Repérer les clés révoquées (`is_revoked = t`) et les clés déjà émises pour le même courriel avant d'en créer une nouvelle.
|
||||
2. **Clé admin hors transcript** : Max la dépose depuis son propre terminal (jamais en `!`, qui écrirait la clé dans la transcription). Valeur : Coolify → maximus-api → Environment Variables → `ADMIN_API_KEY`.
|
||||
```
|
||||
printf '%s' 'LA_CLÉ' > ~/.maximus-admin-key && chmod 600 ~/.maximus-admin-key
|
||||
```
|
||||
Guillemets simples obligatoires (pas d'expansion, pas de retour à la ligne). Si l'historique shell est actif : `history -d $(history 1 | awk '{print $1}')`.
|
||||
3. **Confirmer les paramètres** avec Max : courriel du bénéficiaire (affiché dans la carte licence), `edition` (`base` | `premium`), `machine_limit` (défaut 3, max 10), `expires_in_days` (défaut et max 3650). Puis **émettre** avec le script (lit `~/.maximus-admin-key`, écrit les clés dans `~/simpl-resultat-licences-<date>.txt` en 600, n'affiche que courriel / édition / expiration) :
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
KEY=$(cat ~/.maximus-admin-key); OUT=~/simpl-resultat-licences-$(date +%F).txt; umask 077; : > "$OUT"
|
||||
while IFS='|' read -r email edition; do
|
||||
resp=$(curl -sS -X POST https://api.lacompagniemaximus.com/v1/licenses/generate \
|
||||
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
|
||||
-d "{\"product\":\"simpl-resultat\",\"email\":\"$email\",\"edition\":\"$edition\",\"expires_in_days\":3650,\"machine_limit\":3}")
|
||||
printf '%s | %s | %s\n' "$email" "$edition" "$(echo "$resp" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("expires_at"))')"
|
||||
printf '%s\t%s\t%s\n' "$email" "$edition" "$(echo "$resp" | python3 -c 'import json,sys;print(json.load(sys.stdin)["key"])')" >> "$OUT"
|
||||
sleep 13 # rate limit 5/min
|
||||
done <<'EOF'
|
||||
<courriel>|premium
|
||||
EOF
|
||||
echo "Clés dans $OUT"
|
||||
```
|
||||
Une ligne `courriel|edition` par bénéficiaire (une seule pour un cadeau ou une compensation). Le script vit dans le repo (`scripts/issue-license.sh`, jamais la liste des clés) ou dans le scratchpad — pas de clé dans git ni dans la conversation. Ne jamais `cat` le fichier de sortie dans la session.
|
||||
4. **Vérifier la clé sans l'afficher**, contre le validateur du client (`src-tauri/src/commands/license_commands.rs` : `PUBLIC_KEY_PEM` embarquée, identique entre les versions publiées, et `strict_validation()`) : lire la clé depuis le fichier dans un script (jamais en argument de commande), vérifier le préfixe `SR-<EDITION>-`, la signature Ed25519 du JWT, la présence de `exp` et `iat`, `edition` et `machine_limit` attendus. N'imprimer que le verdict.
|
||||
5. **Livrer** : le fichier va sur le poste de Max via `transfer-asset` (Pop!_OS, `~/Documents/Projets/simpl-resultat/`), avec `scp -p` pour garder le 600 et `sha256sum` des deux côtés ; annoncer chemin, taille, permissions et empreinte tronquée, jamais le contenu. Fournir la marche à suivre (FR/EN) au bénéficiaire : Paramètres → Utilisateurs → carte Licence → coller → « Activer ». Premium avec cours : cliquer aussi « Activer cette machine ».
|
||||
6. **Tracer sans la clé** : mémoire projet (qui, quand, édition, où est le fichier), ligne dans `STATE.md`. Proposer `rm ~/.maximus-admin-key` une fois terminé (la clé reste dans Coolify).
|
||||
7. **Si le déclencheur est une release** : `/release` seulement après. Ordre inverse = bénéficiaires coincés en Free sans auto-update.
|
||||
|
||||
## Règles
|
||||
|
||||
- La clé admin et les clés émises ne vont **jamais** dans la conversation, une mémoire, un repo ou une issue.
|
||||
- Le courriel d'un bénéficiaire tiers ne va pas dans ce fichier ni ailleurs dans le repo : le dépôt `Simpl-Resultat` est **public**. Le désigner par son identifiant Logto ; le courriel se relit au moment d'émettre.
|
||||
- Émettre **avant** de tagger une release qui resserre le gating : l'auto-update est Base+ depuis v0.15.0, donc une installation sans clé reçoit cette version, puis plus rien.
|
||||
- L'activation machine (`activation.token`) n'est requise que pour les endpoints serveur (cours du Bilan) ; la clé seule débloque tout le reste, hors ligne.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Le classifieur du mode auto bloque tout `ssh` vers le VPS (Tailscale comme IP publique) : remettre la commande à Max en `!` avec le texte exact plutôt que la reformuler ou contourner.
|
||||
- `Connection timed out` sans lien de reauth Tailscale = clé de nœud expirée (180 j), pas une attente navigateur — voir le skill global `transfer-asset`.
|
||||
- `.rpm` : aucune mise à jour quelle que soit la licence (#320).
|
||||
- Un testeur multi-profils qui monte en v0.15.0 sans clé reste coincé sur le profil actif au moment de l'upgrade (données intactes, ADR 0017 §5). Pas de clause de grand-père ni de période de grâce dans le code ; `SR_DEV_EDITION` est compilé hors des builds publiés.
|
||||
- Le binaire v0.14.0 appelle encore `/licenses/activate` (#270 non livré) : le serveur répond 308 → `/v1/licenses/activate` et infère le produit du préfixe `SR-` (#48) — ça passe.
|
||||
- Tenir la liste des bénéficiaires ici, par identifiant Logto, pas dans Forgejo (aucune org, 3 comptes seulement) : la source des courriels est la table `users` de Logto (tenant `default`).
|
||||
|
||||
## Changelog
|
||||
|
||||
- 2026-09-13 — Creation initiale via /skills-review-inbox (draft 2026-09-06, source 690a6129). Procedure d'emission de licences testeurs reconstituee avant la release v0.15.0 (verrouillage par edition) ; faits verifies sur le code v0.14.0 -> main et maximus-api prod au 2026-09-06.
|
||||
- 2026-09-13 — Fusion de la proposition `issue-tester-licence` (draft 2026-09-12, même source 690a6129) et élargissement du périmètre à tout bénéficiaire hors Stripe (cadeaux, compensation après remboursement, compte admin), décision Max. Ajouts : sémantique des clés révoquées, dépôt de la clé admin hors historique shell, étape de vérification locale de la clé sans l'afficher, livraison `scp -p` + `sha256sum`, traçabilité sans la clé et retrait de la clé admin, section Règles, gotchas classifieur `ssh` VPS et clé de nœud Tailscale expirée. Heredoc du script passé en placeholder.
|
||||
- 2026-09-24 — Courriel personnel de Michel retiré avant le premier commit : le dépôt est public et l'adresse n'était dans aucun fichier suivi. Il est désigné par son identifiant Logto `michelt`, et une règle interdit le courriel d'un tiers dans le repo. Le courriel de Max est conservé : il figure déjà dans trois fichiers suivis (ADR 0011, ADR 0013, `docs/api-contract-prices.md`). Source : session 997f21af (/warmup).
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
name: release
|
||||
description: Release a new version of Simpl-Resultat (bump, changelog, tag, push)
|
||||
user-invocable: true
|
||||
updated: 2026-07-27
|
||||
updated: 2026-09-13
|
||||
---
|
||||
|
||||
# /release — Release Simpl-Resultat
|
||||
|
|
@ -16,6 +16,7 @@ updated: 2026-07-27
|
|||
## Workflow
|
||||
|
||||
0. **Pré-vol — revalider le tip localement.** Les workflows `check-rust.yml` / `check-frontend.yml` ne tournent pas sur `main` : le tip mergé n'a jamais été vu par le CI (le dernier run vert portait sur la branche d'issue avant merge), et le tag grave ce tip exact dans des binaires distribués. Lancer `npm run build && npm test` (vitest) + `cd src-tauri && cargo check && cargo test`. Vérifier aussi que `.claude/worktrees/` est vide (worktrees leftover → vitest récurse et gonfle le compteur). Ne tagger que sur un tip vert.
|
||||
**Accès des testeurs.** Si `[Unreleased]` touche `FEATURE_TIERS`, le gating par édition ou `auto-update` : vérifier que chaque testeur (Max, Michel) a une clé Base+ **saisie avant le tag**, émise via `/issue-license` au besoin — la clé s'entre dans la version installée et survit à la mise à jour ; une installation sans clé reçoit cette version, puis plus rien. Cas réel v0.15.0 : deux clés Premium émises le jour même, juste avant le tag.
|
||||
1. Determiner la nouvelle version (argument utilisateur ou demander)
|
||||
2. Bump version dans les 5 fichiers :
|
||||
- `src-tauri/Cargo.toml` (ligne `version = "..."`)
|
||||
|
|
@ -39,8 +40,11 @@ updated: 2026-07-27
|
|||
7. Push : `git push origin main && git push origin vX.Y.Z`
|
||||
8. Forgejo CI build automatique (Windows + Linux) via `release.yml` sur `on: push: tags: v*`
|
||||
9. **Post-CI — vérifier la release publiée.** Surveiller `release.yml` (outil `Monitor` sur le run), puis vérifier la release réellement attachée — `status=success` du workflow ne suffit pas :
|
||||
- Les **7 artefacts** attendus : `.exe` NSIS, `.deb`, `.rpm`, leurs 3 signatures `.sig`, et `latest.json`.
|
||||
- Le contenu de `latest.json` (il pilote l'auto-update des installations existantes) : champ `version` correct, signatures non vides pour les deux plateformes, URLs pointant vers les bons binaires, notes extraites du CHANGELOG.
|
||||
```bash
|
||||
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`, `pub_date`, signature non vide et URL contenant la version pour `linux-x86_64` et `windows-x86_64`, notes non vides. Toute ligne `MANQUANT`, `VIDE` ou `MAUVAISE VERSION` bloque l'étape 10.
|
||||
Durée observée du run : 22 à 30 min sur les 12 dernières releases — poser le `Monitor` puis passer à autre chose.
|
||||
10. **Post-CI — dérouler un vrai cycle de mise à jour** si la release touche `tauri-plugin-updater`, ses dépendances (`reqwest`/`rustls-webpki`, `tar`), `release.yml`, ou la config `updater` de `tauri.conf.json` : [`docs/qa-update-cycle.md`](../../../docs/qa-update-cycle.md).
|
||||
|
||||
Inspecter `latest.json` prouve qu'il est **bien formé**, pas qu'il **installe**. Le reste de la chaîne — téléchargement TLS, vérification de signature, exécution de l'installeur, redémarrage — n'est exercé par aucun test : `cargo check`/`cargo test` prouvent que ce code compile, rien de plus. Une régression s'y voit chez l'utilisateur, au moment de la mise à jour.
|
||||
|
|
@ -63,3 +67,5 @@ updated: 2026-07-27
|
|||
- 2026-07-01 — Documenter que le header FR est `## [Non publié]` (≠ `[Unreleased]`), pour éviter le faux diagnostic « changelog FR vide » lors de la migration. Source : session 5466da98.
|
||||
- 2026-07-13 — Étape 0 (pré-vol) : revalider le tip localement avant de tagger — `check.yml` ne tourne pas sur `main`, le tip mergé n'a jamais été vu par le CI ; vérifier `.claude/worktrees/` vide (vitest récurse sinon). Étape 9 (post-CI) : vérifier la release publiée — 7 artefacts attendus + contenu de `latest.json` (pilote l'auto-update) ; `status=success` ne suffit pas. Règle : tagger publie vers l'extérieur (updater automatique) → confirmer avec Max avant de tagger. Source : session fdda84cb (release v0.13.0).
|
||||
- 2026-07-27 — Étape 10 (post-CI) : dérouler un vrai cycle de mise à jour via `docs/qa-update-cycle.md` quand la release touche l'updater. Inspecter `latest.json` prouve qu'il est bien formé, pas qu'il installe : le téléchargement TLS, la vérification de signature, l'exécution de l'installeur et le redémarrage ne sont exercés par aucun test. Deux pièges qui font cocher sans vérifier — tester depuis la machine déjà à jour (`check()` répond « à jour ») et tester sans clé Base+ (auto-update verrouillé par édition, `notEntitled` avant toute requête). Source : session 50ac88d9 (#315, découvert en traitant les advisories `tar`/`rustls-webpki` de #310).
|
||||
- 2026-09-13 — Étape 0 (pré-vol) : cross-référence vers `/issue-license` quand la release modifie `FEATURE_TIERS` ou verrouille une fonctionnalité Free — émettre et distribuer les clés testeurs avant de tagger, sinon ils perdent l'auto-update. Source : session 690a6129 via /skills-review-inbox (draft 2026-09-06).
|
||||
- 2026-09-13 — Étape 0 : prérequis « clés testeurs saisies avant le tag » précisé (déclencheurs `FEATURE_TIERS`, gating, `auto-update` ; cas réel v0.15.0, deux clés Premium émises le jour même). Étape 9 : script `scripts/verify-release.sh` (7 artefacts par suffixe + contenu de `latest.json`) au lieu d'un script réécrit à chaque release ; durée de run observée 22-30 min. Source : session 690a6129 (release v0.15.0, run 402) via /skills-review-inbox (draft 2026-09-12).
|
||||
|
|
|
|||
52
.claude/skills/release/scripts/verify-release.sh
Executable file
52
.claude/skills/release/scripts/verify-release.sh
Executable file
|
|
@ -0,0 +1,52 @@
|
|||
#!/usr/bin/env bash
|
||||
# Step 9 of /release: verify the published Forgejo release for a tag —
|
||||
# the 7 expected assets and the content of latest.json (drives auto-update).
|
||||
set -uo pipefail
|
||||
TAG="${1:?usage: verify-release.sh vX.Y.Z}"
|
||||
VERSION="${TAG#v}"
|
||||
TOKEN=$(cat ~/.forgejo-token)
|
||||
API="https://git.lacompagniemaximus.com/api/v1/repos/maximus/simpl-resultat"
|
||||
WORK="${TMPDIR:-/tmp}/verify-release-${TAG}"; mkdir -p "$WORK"
|
||||
OUT="$WORK/latest.json"
|
||||
|
||||
echo "=== release ${TAG} ==="
|
||||
timeout 20 curl -s -H "Authorization: token $TOKEN" "${API}/releases/tags/${TAG}" > "$WORK/release.json"
|
||||
python3 - "$WORK/release.json" "$VERSION" <<'EOF'
|
||||
import sys, json
|
||||
r = json.load(open(sys.argv[1])); version = sys.argv[2]
|
||||
if "id" not in r:
|
||||
print("PAS DE RELEASE :", r); sys.exit(1)
|
||||
print(f"name={r.get('name')!r} tag={r.get('tag_name')} draft={r.get('draft')} prerelease={r.get('prerelease')} assets={len(r.get('assets', []))}")
|
||||
names = {}
|
||||
for a in r.get("assets", []):
|
||||
n = a["name"]; s = a["size"]; names[n] = a["browser_download_url"]
|
||||
print(f" {n} {s/1_000_000:.1f} MB" if s > 100_000 else f" {n} {s} B")
|
||||
# Naming conventions vary by Tauri version — report by suffix rather than failing on exact names.
|
||||
kinds = {"exe": 0, "exe.sig": 0, "deb": 0, "deb.sig": 0, "rpm": 0, "rpm.sig": 0, "latest.json": 0}
|
||||
for n in names:
|
||||
for k in ("exe.sig", "deb.sig", "rpm.sig"):
|
||||
if n.endswith("." + k): kinds[k] += 1; break
|
||||
else:
|
||||
for k in ("exe", "deb", "rpm"):
|
||||
if n.endswith("." + k): kinds[k] += 1; break
|
||||
else:
|
||||
if n == "latest.json": kinds["latest.json"] += 1
|
||||
missing = [k for k, v in kinds.items() if v == 0]
|
||||
print("couverture:", kinds, "| MANQUANT:" if missing else "| complet", missing if missing else "")
|
||||
open(sys.argv[1] + ".latest-url", "w").write(names.get("latest.json") or "")
|
||||
EOF
|
||||
|
||||
URL=$(cat "$WORK/release.json.latest-url" 2>/dev/null)
|
||||
if [ -z "$URL" ]; then echo "latest.json absent — arrêt"; exit 1; fi
|
||||
echo; echo "=== latest.json ($URL) ==="
|
||||
timeout 20 curl -sL "$URL" > "$OUT"
|
||||
python3 - "$OUT" "$VERSION" <<'EOF'
|
||||
import sys, json
|
||||
j = json.load(open(sys.argv[1])); version = sys.argv[2]
|
||||
v = j.get("version"); pub = j.get("pub_date"); notes = j.get("notes", "")
|
||||
print(f"version={v} {'OK' if v == version else 'MAUVAISE VERSION'} | pub_date={pub}")
|
||||
print(f"notes: {len(notes)} caractères — début: {notes[:120]!r}")
|
||||
for plat, p in j.get("platforms", {}).items():
|
||||
sig = p.get("signature", ""); url = p.get("url", "")
|
||||
print(f" {plat}: url={url.rsplit('/', 1)[-1]} | signature={'OK (' + str(len(sig)) + ' car.)' if sig else 'VIDE'} | url contient version={'oui' if version in url else 'NON'}")
|
||||
EOF
|
||||
Loading…
Reference in a new issue