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 (SettingsSecrets and variablesActions), 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 ?

  1. Connectez-vous à votre admin Ghost
  2. Allez dans SettingsIntegrations
  3. Cliquez sur Add custom integration
  4. Nommez-la "GitHub Actions" ou "Obsidian Sync"
  5. Copiez la clé Admin API Key (format: id:secret avec 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 :

  1. Placez les images dans le dossier assets/ de l'article
  2. 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

  1. Vérifiez les logs GitHub Actions
  2. Confirmez que g_published: true dans le frontmatter
  3. 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


Article mis à jour le 14 février 2026 - Workflow v1.6.0 avec détection intelligente des modifications