Automatiser le déploiement d'articles sur Ghost avec GitHub Actions
Automatiser le déploiement d'articles sur Ghost avec GitHub Actions
Ce guide explique comment configurer un workflow GitHub Actions pour déployer automatiquement des articles Markdown sur un blog Ghost à chaque création de tag. Le workflow détecte intelligemment les modifications et évite de créer des doublons.
Pourquoi cette approche ?
- Écrire dans Obsidian : Meilleure expérience d'édition avec frontmatter
- Contrôle de version : Tous les articles sous Git avec historique complet
- Déploiement automatique : Push un tag → publication automatique
- Pas de doublons : Détection des modifications, mise à jour uniquement si nécessaire
- Workflow CI/CD : Intégration moderne et professionnelle
Architecture du projet
Repository GitHub
├── .github/workflows/
│ └── deploy-ghost.yml # Workflow de déploiement
├── blog-publish/
│ └── nom-de-larticle/
│ ├── nom-de-larticle.md # Article avec frontmatter YAML
│ └── assets/ # Images et fichiers joints
└── README.md
Prérequis
- Un blog Ghost (self-hosted ou Ghost Pro)
- Un repository GitHub
- L'Admin API Key de Ghost (pas la Content API Key)
Configuration
1. Secrets GitHub
Dans votre repository GitHub (Settings → Secrets and variables → Actions), ajoutez :
# URL du blog Ghost
gh secret set GHOST_URL -b"https://votre-blog.com/" -R username/repo
# Admin API Key de Ghost (Settings → Integrations dans Ghost Admin)
gh secret set GHOST_ADMIN_API_KEY -b"votre-id:votre-secret" -R username/repo
Où trouver l'Admin API Key ?
- Connectez-vous à votre admin Ghost
- Allez dans Settings → Integrations
- Cliquez sur Add custom integration
- Nommez-la "GitHub Actions" ou "Obsidian Sync"
- Copiez la clé Admin API Key (format:
id:secretavec deux points)
2. Workflow GitHub Actions
Créez le fichier .github/workflows/deploy-ghost.yml :
name: Deploy to Ghost Blog
on:
push:
tags:
- '*'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Find markdown articles
id: find-articles
run: |
TAG_NAME=${GITHUB_REF#refs/tags/}
echo "Tag created: $TAG_NAME"
if [ -d "blog-publish" ]; then
ARTICLES=$(find blog-publish -mindepth 2 -maxdepth 2 -type f -name "*.md" 2>/dev/null || true)
else
echo "blog-publish/ directory not found"
echo "articles_found=false" >> $GITHUB_OUTPUT
exit 0
fi
if [ -z "$ARTICLES" ]; then
echo "No markdown articles found in blog-publish/"
echo "articles_found=false" >> $GITHUB_OUTPUT
exit 0
fi
echo "Found articles in blog-publish/:"
echo "$ARTICLES"
echo "articles<<EOF" >> $GITHUB_OUTPUT
echo "$ARTICLES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
echo "articles_found=true" >> $GITHUB_OUTPUT
- name: Deploy to Ghost
if: steps.find-articles.outputs.articles_found == 'true'
env:
GHOST_URL: ${{ secrets.GHOST_URL }}
GHOST_ADMIN_API_KEY: ${{ secrets.GHOST_ADMIN_API_KEY }}
run: |
npm install js-yaml markdown-it jsonwebtoken
cat > deploy.js << 'EOF'
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const MarkdownIt = require('markdown-it');
const jwt = require('jsonwebtoken');
const md = new MarkdownIt();
const version = 'v4';
function parseFrontmatter(content) {
const frontmatterRegex = /^---\n([\s\S]*?)\n---/;
const match = content.match(frontmatterRegex);
if (!match) {
return { frontmatter: {}, body: content };
}
try {
const frontmatter = yaml.load(match[1]);
const body = content.replace(frontmatterRegex, '').trim();
return { frontmatter, body };
} catch (e) {
return { frontmatter: {}, body: content };
}
}
function generateToken(apiKey) {
const [id, secret] = apiKey.split(':');
return jwt.sign({}, Buffer.from(secret, 'hex'), {
keyid: id, algorithm: 'HS256', expiresIn: '5m',
audience: `/${version}/admin/`
});
}
function normalizeHtml(html) {
return html.replace(/>\s+</g, '><').replace(/\s+/g, ' ').trim();
}
async function makeRequest(url, method, token, body = null) {
const options = {
method: method,
headers: { 'Content-Type': 'application/json', 'Authorization': `Ghost ${token}` }
};
if (body) options.body = JSON.stringify(body);
const response = await fetch(url, options);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
}
async function deployArticle(filePath) {
const fullContent = fs.readFileSync(filePath, 'utf8');
const { frontmatter, body } = parseFrontmatter(fullContent);
const parentDir = path.basename(path.dirname(filePath));
const h1Match = body.match(/^# (.+)$/m);
const title = frontmatter.g_title || frontmatter.title || h1Match?.[1] || parentDir;
const slug = frontmatter.g_slug || frontmatter.slug || title.toLowerCase().replace(/[^a-z0-9]+/g, '-');
const token = generateToken(process.env.GHOST_ADMIN_API_KEY);
const html = md.render(body);
// Check if post exists
let existingPost = null;
try {
const searchUrl = `${process.env.GHOST_URL}/ghost/api/${version}/admin/posts/slug/${slug}/`;
const result = await makeRequest(searchUrl, 'GET', token);
if (result.posts?.length > 0) existingPost = result.posts[0];
} catch (e) {}
const post = {
title, html,
tags: frontmatter.g_tags || frontmatter.tags || [],
featured: frontmatter.g_featured === true || frontmatter.featured === true,
status: (frontmatter.g_published === true || frontmatter.published === true) ? 'published' : 'draft',
excerpt: frontmatter.g_excerpt || frontmatter.excerpt,
feature_image: frontmatter.g_feature_image || frontmatter.feature_image,
visibility: frontmatter.g_post_access || frontmatter.visibility,
published_at: frontmatter.g_published_at || frontmatter.published_at
};
if (existingPost) {
// Compare content
if (normalizeHtml(existingPost.html || '') === normalizeHtml(html)) {
console.log(`⏭ "${title}" - No changes, skipped`);
return;
}
// Update existing
post.id = existingPost.id;
const updateUrl = `${process.env.GHOST_URL}/ghost/api/${version}/admin/posts/${existingPost.id}/?source=html`;
const result = await makeRequest(updateUrl, 'PUT', token, { posts: [post] });
console.log(`✓ Updated: "${result.posts[0].title}" (${result.posts[0].status})`);
} else {
// Create new
const createUrl = `${process.env.GHOST_URL}/ghost/api/${version}/admin/posts/?source=html`;
const result = await makeRequest(createUrl, 'POST', token, { posts: [post] });
console.log(`✓ Created: "${result.posts[0].title}" (${result.posts[0].status})`);
}
}
async function main() {
const articles = process.env.ARTICLES.split('\n').filter(f => f.trim());
for (const article of articles) {
await deployArticle(article.trim());
}
console.log('\n✓ Deployment complete!');
}
main().catch(e => { console.error(e); process.exit(1); });
EOF
export ARTICLES="${{ steps.find-articles.outputs.articles }}"
node deploy.js
3. Frontmatter supporté
Le workflow supporte ces propriétés YAML (compatibles avec les plugins Ghost pour Obsidian) :
| Propriété | Description | Exemple |
|---|---|---|
g_published |
Statut publication | true ou false |
g_published_at |
Date programmée | "2026-02-20T10:00:00.000Z" |
g_featured |
Article mis en avant | true ou false |
g_tags |
Tags | ["obsidian", "ghost"] |
g_excerpt |
Résumé | "Description courte" |
g_feature_image |
Image de couverture | "https://.../image.jpg" |
g_slug |
URL personnalisée | "mon-article" |
g_post_access |
Visibilité | public, members, paid |
Exemple d'article complet :
---
g_published: true
g_featured: false
g_tags:
- tutoriel
- automation
g_excerpt: "Comment automatiser vos publications"
g_slug: "mon-tutoriel-automation"
g_post_access: public
---
# Mon Tutoriel Automation
Contenu de l'article en **Markdown** avec:
- Listes
- *Italique*
- **Gras**
- `code inline`
- Et bien plus !
Utilisation quotidienne
Créer un nouvel article
# 1. Créer la structure
mkdir -p blog-publish/mon-nouvel-article/assets
# 2. Écrire l'article
cat > blog-publish/mon-nouvel-article/mon-nouvel-article.md << 'EOF'
---
g_published: true
g_tags: [test]
---
# Mon Nouvel Article
Contenu ici...
EOF
# 3. Commit et push
git add blog-publish/mon-nouvel-article/
git commit -m "Add: Mon nouvel article"
git tag v1.2.0
git push origin v1.2.0
Mettre à jour un article existant
Modifiez simplement le fichier et poussez un nouveau tag :
# Modifier l'article
vim blog-publish/mon-article/mon-article.md
# Commit et tag
git add blog-publish/mon-article/mon-article.md
git commit -m "Update: Corrections et améliorations"
git tag v1.2.1
git push origin v1.2.1
Le workflow détectera automatiquement les changements et mettra à jour l'article sans créer de doublon.
Fonctionnalités clés
Détection des modifications
Le workflow compare le contenu HTML normalisé (sans espaces superflus) pour détecter les changements réels :
- ✅ Pas de modification → Skip avec message
⏭ "Titre" - No changes, skipped - ✅ Modification détectée → Update avec
✓ Updated: "Titre" - ✅ Nouvel article → Create avec
✓ Created: "Titre"
Compatibilité plugin Obsidian
Ce workflow est conçu pour être 100% compatible avec le plugin Send to Ghost pour Obsidian :
- Même format de frontmatter (
g_*) - Même endpoint API (
?source=html) - Même librairie de conversion (
markdown-it) - Même version API (
v4)
Vous pouvez utiliser les deux méthodes simultanément :
- Plugin : Publication rapide depuis Obsidian
- CI/CD : Workflow automatisé avec versionning
Gestion des images
Pour inclure des images dans vos articles :
- Placez les images dans le dossier
assets/de l'article - Ou dans le contenu Markdown (note : les images doivent être uploadées manuellement ou via une intégration séparée)
Référencez-les dans le frontmatter :
g_feature_image: "./assets/hero-image.jpg"
Dépannage
L'article n'apparaît pas sur Ghost
- Vérifiez les logs GitHub Actions
- Confirmez que
g_published: truedans le frontmatter - Vérifiez que le slug n'est pas déjà utilisé par une page
Erreur "HTTP 401" ou "Unauthorized"
- Vérifiez que vous utilisez l'Admin API Key (pas la Content API Key)
- Assurez-vous que la clé est correctement définie dans les secrets GitHub
- La clé doit contenir deux points (
id:secret)
Les modifications ne sont pas détectées
Le workflow normalise le HTML pour la comparaison :
- Espaces entre balises supprimées
- Espaces multiples réduits
- Si vous voyez toujours "No changes", vérifiez que le contenu Markdown a réellement changé
Workflow complet - Récapitulatif
graph TD
A[Push Tag] --> B[Checkout Repository]
B --> C[Find .md files in blog-publish/]
C --> D{Articles found?}
D -->|No| E[Skip deployment]
D -->|Yes| F[Parse frontmatter YAML]
F --> G[Convert Markdown → HTML]
G --> H{Post exists?}
H -->|No| I[Create new post]
H -->|Yes| J{Content changed?}
J -->|No| K[Skip: No changes]
J -->|Yes| L[Update existing post]
I --> M[Deployment complete]
K --> M
L --> M
Références
- Ghost Admin API Documentation
- GitHub Actions Documentation
- Send to Ghost Plugin
- Ghost Writer Manager Plugin
- markdown-it Documentation
Article mis à jour le 14 février 2026 - Workflow v1.6.0 avec détection intelligente des modifications