TL;DR
Depuis le 2026-07-08, la CI GitHub Actions est désactivée sur le dépôt du blog (RAPPORT-EXPERIENTIEL). Il n'y a plus aucun repli automatique : le chemin local-first est le seul moyen de publier et de déployer. Publier = un appel HTTP à l'API admin (ou l'outil MCP), qui écrit le .md et l'horodate en heure Europe/Paris. Déployer = npm run build:public puis un rollout statique direct vers le VPS via SSH, suivi d'une auto-réparation des permissions. Ce guide donne les étapes exactes, avec exemples copiables.
1. Pourquoi ce changement
L'ancien pipeline reposait sur deux béquilles fragiles, toutes deux supprimées :
- La CI auto-commit / auto-deploy : un
pushdéclenchait un workflow qui régénérait l'index et déployait. Elle est désactivée entièrement — ne comptez plus dessus, ne la présentez plus comme une option. - L'ancien
execSync("node build-index.mjs")synchrone dans le chemin de publication MCP : bloquant, sensible aux timeouts, et il « perdait » silencieusement des articles (écrits endraftsur disque mais jamais marquéspubsi le matching échouait).
À la place, un module partagé unique scripts/lib/publish-article.mjs (fonction publishArticle) sert les deux points d'entrée (API admin + MCP). Il applique systématiquement le garde-fou d'horodatage scripts/lib/publish-time.mjs (heure civile Europe/Paris, refus de toute date dans le futur). Publier n'exige plus aucun git commit/git push pour exister.
2. Publier un article — API admin (POST :5220)
Prérequis
Depuis le dépôt canonique rapport-experentiel/, lancer les deux serveurs locaux :
# Serveur de lecture (:5195) — sert GET /api/articles
$env:PORT="5195"; $env:HOST="127.0.0.1"; node src/server.js
# Serveur admin (:5220) — expose POST /admin/api/articles (Basic auth)
$env:ADMIN_PASSWORD="<ton-mot-de-passe>"; node admin/server.mjs
Important : lance-les depuis
rapport-experentiel/(le dépôt canonique).POSTS_DIRest résolu par rapport à l'emplacement du fichier serveur — un lancement depuis un autre clone écrirait dans le mauvais dossier.
L'appel de publication
Corps JSON accepté : cat, file (optionnel — sinon nom auto AAAA-MM-JJ-HHMM_slug.md), title, subtitle, author, tags, status (pub ou draft), body (Markdown). Auth : Basic admin:$ADMIN_PASSWORD.
En PowerShell :
$pair = "admin:$env:ADMIN_PASSWORD"
$b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($pair))
$payload = @{
cat = "workflows-agentiques"
title = "Mon nouvel article"
subtitle = "Sous-titre optionnel"
author = "cursor-agent"
tags = @("workflows-agentiques","demo")
status = "pub"
body = "## Section`n`nLe contenu Markdown de l'article."
} | ConvertTo-Json
Invoke-RestMethod -Uri "http://127.0.0.1:5220/admin/api/articles" `
-Method Post -ContentType "application/json" `
-Headers @{ Authorization = "Basic $b64" } -Body $payload
Équivalent curl :
curl -u "admin:$ADMIN_PASSWORD" -H "Content-Type: application/json" \
-X POST http://127.0.0.1:5220/admin/api/articles \
-d '{"cat":"workflows-agentiques","title":"Mon nouvel article","author":"cursor-agent","tags":["demo"],"status":"pub","body":"## Section\n\nContenu."}'
La réponse renvoie { success: true, article: { url, file, cat, date, time, ... } }. La date et l'heure sont posées par le garde-fou — ne les fabrique pas à la main.
Vérification de relecture (GET :5195)
$r = Invoke-RestMethod "http://127.0.0.1:5195/api/articles"
$r.articles | Where-Object { $_.url -match "mon-nouvel-article" } | Select-Object url, date, time
Si l'article ressort ici, l'écriture local-first a réussi.
3. Publier un article — outil MCP vfg_commands_devblog_publish
L'outil MCP génère l'article depuis un sujet, l'enregistre dans RAPPORT-EXPERIENTIEL et (option) le publie via le même backend admin. Arguments : topic (requis), model (défaut claude), auto_publish (défaut false).
{
"server": "user-vfg-commands",
"toolName": "vfg_commands_devblog_publish",
"arguments": { "topic": "Bilan de la migration local-first", "auto_publish": true }
}
Avec auto_publish: true, l'article passe par publishArticle (même garde-fou horodatage, même dossier canonique). Contrôle ensuite avec le GET :5195/api/articles ci-dessus. Aperçu sans publier : vfg_commands_devblog_preview.
4. Déployer sur le VPS — sans CI
Le blog n'est pas un service hotfix enregistré (le catalogue vfg_hotfix_catalog liste IdeaPit, Pulse, VFJah, Gandalf). On n'utilise donc pas vfg_hotfix_deploy : c'est un site statique, on pousse les fichiers directement.
4.1 Construire le site statique
npm run build:public # regénère dist/ (pages .md.html + index + RSS/Atom)
build:public enchaîne check:publish (garde-fou anti-fuite : aucun chemin local, endpoint interne ou localhost dans dist/).
4.2 Rollout vers le VPS
Chemin recommandé : le script dédié, qui fait build + transfert + restart :
npm run deploy:blog # = scripts/deploy-blog-vfg-lol.ps1
Le script transfère dist/ vers deploy@<VPS>:/var/www/blog.vfg.lol (rsync s'il est présent, sinon scp) via l'accès ssh vps-vfg-deploy. Chemin manuel équivalent (utile sur Windows sans rsync local) :
tar -czf _dist.tar.gz -C dist .
scp _dist.tar.gz vps-vfg-deploy:/tmp/blog-dist.tar.gz
ssh vps-vfg-deploy "rm -rf /tmp/blog-new && mkdir -p /tmp/blog-new && tar -xzf /tmp/blog-dist.tar.gz -C /tmp/blog-new && rsync -a --delete /tmp/blog-new/ /var/www/blog.vfg.lol/ && rm -rf /tmp/blog-new /tmp/blog-dist.tar.gz"
Le compte deploy possède le docroot : il écrit sans sudo. (deploy n'a pas de sudo NOPASSWD — seul le compte admin vps-vfg l'a.)
4.3 Auto-réparation des permissions (idempotente)
rsync -a fixe le groupe sur celui de la source ; on rétablit ensuite l'invariant deploy:www-data + bit setgid pour que nginx lise l'arborescence et que les futurs fichiers héritent du bon groupe :
ssh vps-vfg "sudo chown -R deploy:www-data /var/www/blog.vfg.lol && sudo find /var/www/blog.vfg.lol -type d -exec chmod 2775 {} + && sudo find /var/www/blog.vfg.lol -type f -exec chmod 664 {} +"
4.4 Service et reverse-proxy
Les fichiers sont servis par le conteneur nginx vfg-fleet-blog-static-1 sur 127.0.0.1:8092, exposé en HTTPS par Caddy (systemd) sur blog.vfg.lol. Site statique = aucun redémarrage nécessaire : nginx sert les fichiers à chaud.
4.5 Contrôle de santé
# En local sur le VPS (nginx direct)
ssh vps-vfg-deploy "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8092/"
# Depuis l'extérieur (chaîne Caddy complète)
Invoke-WebRequest https://blog.vfg.lol/ -UseBasicParsing | Select-Object StatusCode
Un 200 des deux côtés = déploiement en ligne. L'URL d'un article suit https://blog.vfg.lol/devblog/posts/<cat>/<fichier>.md.html.
5. Vérification par la gate / le MCP
Outils MCP en lecture seule, utiles pour confirmer l'état de la flotte :
vfg_gate_health— santé de VFGate.vfg_health— santé du bridge VFGate (/api/bridge/v1/health).vfg_hotfix_verify({ service }) — vérifiehealthzlocal/public d'un service hotfix. Le blog n'en étant pas un, on s'appuie sur lecurl/Invoke-WebRequestdu §4.5 pour lui.
6. Dépannage
401surPOST :5220:ADMIN_PASSWORDnon chargé ou différent. Vérifie l'env du process serveur ; l'en-tête doit êtreBasic base64(admin:MOT_DE_PASSE).- L'article n'apparaît pas dans
GET :5195: serveur lancé depuis le mauvais clone. VérifiePOSTS_DIR(node -e "import('./scripts/lib/publish-article.mjs').then(m=>console.log(m.POSTS_DIR))") — il doit pointer surrapport-experentiel/devblog/posts. - Horodatage « corrigé » en console : normal — le garde-fou refuse une date future ou complète une heure manquante avec l'heure réelle de Paris. Ne force pas de date.
check:publishéchoue : une fuite (chemin local, endpoint interne,localhost) a été détectée dansdist/. Corrige la source.mdpuis relancebuild:public.- Site non mis à jour après déploiement : re-vérifie que
rsync --deletea bien tourné et que lecurlsur:8092renvoie la bonne taille ; puis rejoue l'auto-réparation des permissions du §4.3. - Permission refusée sur le VPS :
deploypossède le docroot (écriture OK sans sudo) ; leschown/chmodpassent par le compte adminvps-vfg(sudo NOPASSWD). N'inverse pas les deux.