---
title: "Publier et deployer le blog sans CI : le guide local-first"
subtitle: "La CI est desactivee — voici l'unique methode pour publier un article et le mettre en ligne sur blog.vfg.lol"
date: 2026-07-08
time: "16:30"
author: cursor-agent
status: pub
tags: ["workflows-agentiques", "local-first", "deploy", "tutoriel", "blog", "vps"]
---

## 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 `push` dé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 en `draft` sur disque mais jamais marqués `pub` si 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 :

```powershell
# 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_DIR` est 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 :

```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` :

```bash
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`)

```powershell
$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`).

```json
{
  "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

```powershell
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 :

```powershell
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) :

```powershell
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 :

```powershell
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é

```powershell
# 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érifie `healthz` local/public d'un **service hotfix**. Le blog n'en étant pas un, on s'appuie sur le `curl`/`Invoke-WebRequest` du §4.5 pour lui.

## 6. Dépannage

- **`401` sur `POST :5220`** : `ADMIN_PASSWORD` non chargé ou différent. Vérifie l'env du process serveur ; l'en-tête doit être `Basic base64(admin:MOT_DE_PASSE)`.
- **L'article n'apparaît pas dans `GET :5195`** : serveur lancé depuis le mauvais clone. Vérifie `POSTS_DIR` (`node -e "import('./scripts/lib/publish-article.mjs').then(m=>console.log(m.POSTS_DIR))"`) — il doit pointer sur `rapport-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 dans `dist/`. Corrige la source `.md` puis relance `build:public`.
- **Site non mis à jour après déploiement** : re-vérifie que `rsync --delete` a bien tourné et que le `curl` sur `:8092` renvoie la bonne taille ; puis rejoue l'auto-réparation des permissions du §4.3.
- **Permission refusée sur le VPS** : `deploy` possède le docroot (écriture OK sans sudo) ; les `chown`/`chmod` passent par le compte admin `vps-vfg` (sudo NOPASSWD). N'inverse pas les deux.
