
bencat.dev
Le site que vous êtes en train de consulter : blog, projets, prompts et skills, en Astro, éditable depuis un CMS git sans backend.
// aperçu
Ce site remplace mon ancien blog Hugo. Même foutoir, autre socle : Astro en statique, Tailwind v4 pour le thème, TypeScript pour la logique du site, et Sveltia CMS pour écrire sans toucher au code.
Cette page s’adresse à ceux que la plomberie intéresse. Si vous êtes venus pour le contenu, le blog est par là, j’le vivrai bien.
Ce qu’il y a sous le capot
Le contenu vit en page bundles : un dossier par article, avec ses images à côté, validés par des schémas Zod. Le CMS est une page statique servie sur /admin : le navigateur parle directement à l’API GitHub avec un jeton personnel, donc aucun backend à héberger. Par défaut, un enregistrement reste sur une branche cms/…, et la publication fusionne sur main. Pour une correction immédiate, « Enregistrer et publier » va droit sur main. Dans les deux cas, ça déclenche le déploiement sur mon Raspberry Pi.
Un runner auto-hébergé, ça mérite un peu de parano. Le mien tourne avec un utilisateur sans sudo ni docker, et le workflow n’a aucun déclencheur pull_request : il n’exécute que du code déjà sur main, ou lancé à la main par moi. Son boulot se résume à copier des fichiers sous /srv/site, que Caddy sert en lecture seule.
git push, et après ? Qui m’arrête quand je me plante
Tout passe par un seul fichier, deploy.yml, à lire dans l’onglet Code. Voilà ce qui peut y bloquer une mise en ligne :
| Étape | Où ça tourne | Ce qui peut tout arrêter |
|---|---|---|
| Tests | GitHub Actions | npm test, puis npm run check |
| Build | GitHub Actions, une fois par version | npm run build, ou un artefact vide |
| Publication | Runner du Raspberry Pi | Un artefact sans index.html |
| Contrôle final | Runner du Raspberry Pi | Un accueil servi par Caddy qui diffère de celui publié, ou des brouillons sans mot de passe |
Le contenu a droit à son contrôle technique, lui aussi. Le CMS réécrit mes fichiers à sa façon, alors avant chaque commit, je vérifie que tout est déjà dans la forme qu’il écrirait :
$ TZ=Europe/Paris node scripts/canonicalize-content.mjs --check
canonical: 15/15 entries
$ echo $?
0404 Not Found : la purge qui marchait trop bien
Le Pi garde cinq versions par site, la prod et la preview, et purge les autres. Sur le papier, c’est propre. Dans les faits :
Le correctif tient en deux gestes : incoming/ est vidé avant le téléchargement, sinon les fichiers retirés du site seraient republiés, et la version publiée récupère sa date.
# cp -a recopie la date de $src : sans touch, la purge ci-dessous
# peut trier la nouvelle version en dernier et la supprimer.
touch "$root/$release"
Où j’en suis
Tout est en ligne : le blog, le CMS, la vitrine projets, les bibliothèques de prompts et de skills, et la recherche.
Le site est construit plan par plan, avec une méthode anti-dérive : une spec à critères binaires par plan, un journal de périmètre tenu à jour à chaque tâche, et un audit de vérification obligatoire avant de livrer un plan. Ça fait beaucoup de paperasse pour un site perso, je sais. Mais le premier commit du dépôt, c’est cette méthode et le design, et la méthode dit pourquoi mieux que moi :
Sans cadre, ce type de build multi-sessions perd du périmètre en silence : une page à moitié faite, une collection oubliée, un critère validé « à peu près ».
— la méthode du site, 19 juillet 2026
GitHub Actions, c’est aussi mon terrain de jeu ailleurs, avec cette petite action pour les numéros de version :
Projetgha-svu — le versionnage sémantique dans GitHub ActionsUne action composite qui calcule la prochaine version sémantique avec SVU v3, et pose le tag si tu lui demandes.EXIT 0
Si vous lisez ces lignes, c’est que la purge s’est tenue tranquille. Merci d’être passés !
// stack
// ce qui fait tourner le projet
Astro
statique
Tailwind CSS
thème
TypeScript
la logique du site
Sveltia CMS
écrire sans toucher au code
Raspberry Pi
déploiement
// code
.github/workflows/deploy.yml
Ce fichier est le déploiement du site : GitHub teste et construit deux versions (publique et brouillons), puis le runner du Raspberry Pi les copie sous /srv/site, bascule vers la nouvelle version et vérifie ce que Caddy sert.
name: Deploy to bencat.dev
# Build sur GitHub, publication sur le Raspberry Pi (spec migration-pi, M2) :
# le job `deploy` tourne sur le runner auto-hébergé `pi-site` (utilisateur
# `site`, ni sudo ni docker) et ne fait que copier les artefacts sous
# /srv/site, que Caddy (pile babyzcoming) sert en lecture seule.
# - prod → https://bencat.dev
# - preview → https://preview.bencat.dev (brouillons publiés, mot de passe)
# Jamais de déclencheur pull_request ici : un runner auto-hébergé n'exécute
# que du code déjà sur main (ou lancé à la main par Ben).
on:
push: { branches: [main] }
workflow_dispatch:
permissions: { contents: read }
concurrency:
group: deploy
cancel-in-progress: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
- run: npm run check
build:
needs: test
runs-on: ubuntu-latest
strategy:
matrix:
target: [prod, preview]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Publier les brouillons (preview seulement)
if: matrix.target == 'preview'
run: node scripts/preview/drafts.mjs
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: site-${{ matrix.target }}
path: dist/
retention-days: 7
if-no-files-found: error
deploy:
needs: build
runs-on: [self-hosted, pi-site]
timeout-minutes: 10
steps:
# Le runner auto-hébergé garde son espace de travail d'un job à l'autre
# et ce job n'a pas de checkout pour le nettoyer : sans ça, les fichiers
# retirés du site survivraient dans incoming/ et seraient republiés.
- name: Vider incoming
run: rm -rf incoming
- uses: actions/download-artifact@v4
with:
pattern: site-*
path: incoming
- name: Publier sous /srv/site (bascule atomique, 5 versions gardées)
run: |
set -euo pipefail
release="releases/${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
for target in prod preview; do
src="incoming/site-$target"
root="/srv/site/$target"
test -f "$src/index.html" || { echo "::error::artefact $target sans index.html"; exit 1; }
mkdir -p "$root/$release"
cp -a "$src/." "$root/$release/"
# cp -a recopie la date de $src : sans touch, la purge ci-dessous
# peut trier la nouvelle version en dernier et la supprimer.
touch "$root/$release"
# Lien RELATIF : il se résout aussi dans le conteneur Caddy.
ln -sfn "$release" "$root/current.next"
mv -T "$root/current.next" "$root/current"
ls -1dt "$root"/releases/*/ | tail -n +6 | xargs -r rm -rf --
echo "$target → $(readlink "$root/current")"
done
- name: Vérifier ce que Caddy sert
run: |
set -euo pipefail
expected=$(sha256sum /srv/site/prod/current/index.html | cut -d' ' -f1)
served=$(curl -fsS --resolve bencat.dev:443:127.0.0.1 https://bencat.dev/ | sha256sum | cut -d' ' -f1)
test "$served" = "$expected" || { echo "::error::Caddy ne sert pas la version publiée"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' --resolve preview.bencat.dev:443:127.0.0.1 https://preview.bencat.dev/)
test "$code" = 401 || { echo "::error::preview.bencat.dev répond $code sans mot de passe (401 attendu)"; exit 1; }
echo "bencat.dev sert la version publiée ; preview.bencat.dev exige un mot de passe."
# Notification de fin de déploiement. Facultative : sans le secret NTFY_URL
# (https://<serveur>/<sujet>), rien n'est envoyé. Seulement sur un push :
# pas de ping à 5 h pour la revue du matin. Un envoi raté n'échoue jamais
# le déploiement.
- name: Prévenir sur ntfy
if: always() && github.event_name == 'push'
continue-on-error: true
env:
NTFY_URL: ${{ secrets.NTFY_URL }}
NTFY_TOKEN: ${{ secrets.NTFY_TOKEN }}
STATUS: ${{ job.status }}
COMMIT: ${{ github.event.head_commit.message }}
run: |
test -n "$NTFY_URL" || exit 0
tag=x; title='Déploiement raté'
if test "$STATUS" = success; then tag=white_check_mark; title="C'est en ligne"; fi
auth=()
test -z "$NTFY_TOKEN" || auth=(-H "Authorization: Bearer $NTFY_TOKEN")
printf '%s : %s' "$title" "${COMMIT%%$'\n'*}" |
curl -fsS --max-time 10 "${auth[@]}" -H "Tags: $tag" -H 'Click: https://bencat.dev' --data-binary @- "$NTFY_URL" >/dev/nullscripts/patch-sveltia.mjs
Ce script corrige Sveltia CMS en local à chaque postinstall : un bogue amont fait perdre ses lignes à un tableau dans l'éditeur, il interdit donc | dans le motif fautif, et il échoue si ce motif disparaît, signal pour retirer le correctif.
#!/usr/bin/env node
// Correctif local de @sveltia/cms (postinstall). Bogue amont, présent au moins de 0.250 à 0.259 :
// `splitMultilineCodeSpans` prend pour UN code sur deux lignes l'espace entre deux lignes de tableau
// quand la première finit par « ponctuation` » et la suivante commence par « `ponctuation », comme
// `:` puis `/`. Le tableau perd ses lignes dans l'éditeur, et la sauvegarde suivante écrit le corps cassé.
// Le correctif interdit `|` dans ce code. Idempotent ; échoue si le motif a disparu : c'est le signal
// pour vérifier le correctif amont et retirer ce script (test : src/lib/sveltiaPatch.test.ts).
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
export const BUGGY = '`([^`\\n]+?)\\n([^`\\n]+?)`(?=[\\s\\p{P}\\p{S}]|$)(?!`)';
export const FIXED = '`([^`|\\n]+?)\\n([^`|\\n]+?)`(?=[\\s\\p{P}\\p{S}]|$)(?!`)';
/** Texte corrigé ; lève si ni le motif bogué ni le motif corrigé n'y figurent exactement une fois. */
export function patchBundle(source) {
if (source.split(FIXED).length === 2) return source;
if (source.split(BUGGY).length !== 2) {
throw new Error('patch-sveltia : motif introuvable, vérifier si le bogue est corrigé en amont puis retirer ce correctif');
}
return source.replace(BUGGY, () => FIXED);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const bundle = new URL('../node_modules/@sveltia/cms/npm/index.js', import.meta.url);
if (existsSync(bundle)) {
const source = readFileSync(bundle, 'utf8');
const patched = patchBundle(source);
if (patched !== source) writeFileSync(bundle, patched);
}
}src/lib/detailTabs.ts
Extrait : cette fonction décide des onglets de la fiche que vous lisez, dans leur ordre, et un onglet sans données n'apparaît pas.
/**
* Ordre d'affichage (plan 24) : Aperçu · Stack · Code · Journal · Articles
* liés ; le premier est sélectionné au chargement. Stack, Code, Journal et
* Articles liés portent leur effectif ; Aperçu n'en a pas. Aperçu n'existe que
* sur un corps non blanc (I3) : ni la couverture ni le code ne le nourrissent.
*/
export function projectTabs(input: ProjectTabInput): Tab[] {
const tabs: Tab[] = [];
if (!isBlank(input.body)) tabs.push({ id: 'apercu', label: 'Aperçu' });
if (input.stackCount > 0) tabs.push({ id: 'stack', label: 'Stack', count: input.stackCount });
if (input.codeCount > 0) tabs.push({ id: 'code', label: 'Code', count: input.codeCount });
if (input.journalCount > 0) {
tabs.push({ id: 'journal', label: 'Journal', count: input.journalCount });
}
if (input.relatedCount > 0) {
tabs.push({ id: 'articles-lies', label: 'Articles liés', count: input.relatedCount });
}
return tabs;
}// journal
La purge met le site en 404
Elle supprimait la version qu'on venait de publier. Corrigé le jour même.
Le site déménage sur le Raspberry Pi
Il quitte GitHub Pages.
v1.6.0 : le tableau de bord Sveltia
Navigation, aperçus, création rapide et blocs de l'éditeur.
v1.5.0 : listes, article et fiches alignés sur le prototype
v1.1.0 : socle du design system
v1.0.0 : site complet
Le CMS et la vitrine projets
v0.1.0 : socle du blog déployé
La méthode avant le code
Le premier commit ne contient aucune ligne de site, seulement la méthode et le design.
// articles liés
// écrit à propos de ce projet