Automate Article Deployment to Ghost with GitHub Actions

Automate Article Deployment to Ghost with GitHub Actions

This complete guide explains how to set up a GitHub Actions workflow to automatically deploy Markdown articles to a Ghost blog whenever you create a tag. The workflow intelligently detects changes and avoids creating duplicates.

Current version: v1.7.5 - Updated February 15, 2026

Why This Approach?

  • Write in Obsidian: Better writing experience with frontmatter
  • Version control: All articles under Git with complete history
  • Automatic deployment: Push a tag → automatic publication
  • No duplicates: Change detection, updates only when necessary
  • CI/CD Workflow: Modern, professional integration

Project Architecture

GitHub Repository
├── .github/workflows/
│   └── deploy-ghost.yml      # Deployment workflow
├── blog-publish/
│   └── article-name/
│       ├── article-name.md   # Article with YAML frontmatter
│       └── assets/           # Images and attachments
└── README.md

Prerequisites

  • A Ghost blog (self-hosted or Ghost Pro)
  • A GitHub repository
  • Ghost Admin API Key (not Content API Key)

Configuration

1. GitHub Secrets

In your GitHub repository (SettingsSecrets and variablesActions), add:

# Ghost blog URL
gh secret set GHOST_URL -b"https://your-blog.com/" -R username/repo

# Ghost Admin API Key (Settings → Integrations in Ghost Admin)
gh secret set GHOST_ADMIN_API_KEY -b"your-id:your-secret" -R username/repo

Where to find the Admin API Key:

  1. Log in to your Ghost admin panel
  2. Go to SettingsIntegrations
  3. Click Add custom integration
  4. Name it "GitHub Actions" or "Obsidian Sync"
  5. Copy the Admin API Key (format: id:secret with a colon)

2. GitHub Actions Workflow

Create the file .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. Supported Frontmatter

The workflow supports these YAML properties (compatible with Ghost plugins for Obsidian):

Property Description Example
g_published Publication status true or false
g_published_at Scheduled date "2026-02-20T10:00:00.000Z"
g_featured Featured post true or false
g_tags Tags ["obsidian", "ghost"]
g_excerpt Summary/excerpt "Short description"
g_feature_image Featured image "https://.../image.jpg"
g_slug Custom URL "my-article"
g_post_access Visibility public, members, paid

Complete article example:

---
g_published: true
g_featured: false
g_tags:
  - tutorial
  - automation
g_excerpt: "How to automate your publishing workflow"
g_slug: "my-automation-tutorial"
g_post_access: public
---

# My Automation Tutorial

Article content in **Markdown** with:
- Lists
- *Italic*
- **Bold**
- `inline code`
- And much more!

Daily Usage

Create a New Article

# 1. Create structure
mkdir -p blog-publish/my-new-article/assets

# 2. Write article
cat > blog-publish/my-new-article/my-new-article.md << 'EOF'
---
g_published: true
g_tags: [test]
---

# My New Article

Content here...
EOF

# 3. Commit and push
git add blog-publish/my-new-article/
git commit -m "Add: My new article"
git tag v1.2.0
git push origin v1.2.0

Update an Existing Article

Simply edit the file and push a new tag:

# Edit article
vim blog-publish/my-article/my-article.md

# Commit and tag
git add blog-publish/my-article/my-article.md
git commit -m "Update: Corrections and improvements"
git tag v1.2.1
git push origin v1.2.1

The workflow will automatically detect changes and update the article without creating duplicates.

Key Features

Change Detection

The workflow compares normalized HTML (without extra spaces) to detect real changes:

  • No changes → Skip with message ⏭ "Title" - No changes, skipped
  • Changes detected → Update with ✓ Updated: "Title"
  • New article → Create with ✓ Created: "Title"

Obsidian Plugin Compatibility

This workflow is designed to be 100% compatible with the Send to Ghost plugin for Obsidian:

  • Same frontmatter format (g_*)
  • Same API endpoint (?source=html)
  • Same conversion library (markdown-it)
  • Same API version (v4)

You can use both methods simultaneously:

  • Plugin: Quick publishing from Obsidian
  • CI/CD: Automated workflow with versioning

Image Management

To include images in your articles:

  1. Place images in the assets/ folder of the article
  2. Or in Markdown content (note: images must be uploaded manually or via separate integration)

Reference them in frontmatter:

g_feature_image: "./assets/hero-image.jpg"

Troubleshooting

Article Not Appearing on Ghost

  1. Check GitHub Actions logs
  2. Confirm g_published: true in frontmatter
  3. Verify the slug is not already used by a page

"HTTP 401" or "Unauthorized" Error

  • Make sure you're using the Admin API Key (not Content API Key)
  • Ensure the key is properly set in GitHub secrets
  • The key must contain a colon (id:secret)

Changes Not Detected

The workflow normalizes HTML for comparison:

  • Spaces between tags are removed
  • Multiple spaces are reduced to one
  • If you still see "No changes", verify that the Markdown content actually changed

Complete Workflow - Summary

The automatic deployment process works as follows:

┌─────────────────────────────────────────────────────────────┐
│                    PUSH TAG ON GITHUB                        │
└────────────────────┬────────────────────────────────────────┘
                     │
                     ▼
┌─────────────────────────────────────────────────────────────┐
│         CHECKOUT REPOSITORY AND SETUP NODE                   │
└────────────────────┬────────────────────────────────────────┘
                     │
                     ▼
┌─────────────────────────────────────────────────────────────┐
│         SEARCH .md FILES IN blog-publish/                   │
└────────────────────┬────────────────────────────────────────┘
                     │
           ┌─────────┴─────────┐
           │                   │
    ┌──────▼──────┐    ┌──────▼──────┐
    │  Articles   │    │   No        │
    │   found     │    │   articles  │
    └──────┬──────┘    └──────┬──────┘
           │                   │
           ▼                   ▼
    ┌──────────────┐    ┌──────────────┐
    │   PARSE      │    │   SKIP       │
    │  FRONTMATTER │    │ DEPLOYMENT   │
    └──────┬───────┘    └──────────────┘
           │
           ▼
    ┌──────────────┐
    │ CONVERT      │
    │  MARKDOWN    │
    │   → HTML     │
    └──────┬───────┘
           │
           ▼
    ┌──────────────┐
    │ CHECK IF     │
    │ POST EXISTS? │
    └──────┬───────┘
           │
   ┌───────┴────────┐
   │                │
   ▼                ▼
┌──────┐      ┌──────────┐
│ NO   │      │   YES    │
└──┬───┘      └────┬─────┘
   │               │
   ▼               ▼
┌──────────┐ ┌─────────────────┐
│ CREATE   │ │ COMPARE HTML    │
│  NEW     │ │ (normalized)    │
│   POST   │ └────────┬────────┘
└──────────┘          │
              ┌───────┴──────┐
              │              │
              ▼              ▼
       ┌──────────┐  ┌──────────┐
       │ SAME     │  │ DIFFERENT│
       └────┬─────┘  └────┬─────┘
            │             │
            ▼             ▼
      ┌──────────┐ ┌──────────┐
      │  SKIP    │ │  UPDATE  │
      │(no change│ │  POST    │
      └──────────┘ └──────────┘
              │             │
              └──────┬──────┘
                     │
                     ▼
            ┌────────────────┐
            │   DEPLOYMENT   │
            │    COMPLETE    │
            └────────────────┘

Step legend:

  1. Trigger: Tag pushed to GitHub
  2. Parse: Extract YAML frontmatter
  3. Convert: Markdown → HTML with markdown-it
  4. Check: Search for existing post by slug
  5. Compare: Compare normalized HTML
  6. Action: Create, update, or skip

References


Article updated February 15, 2026 - Workflow v1.7.5 with ASCII diagram and smart change detection