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 (Settings → Secrets and variables → Actions), 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:
- Log in to your Ghost admin panel
- Go to Settings → Integrations
- Click Add custom integration
- Name it "GitHub Actions" or "Obsidian Sync"
- Copy the Admin API Key (format:
id:secretwith 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:
- Place images in the
assets/folder of the article - 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
- Check GitHub Actions logs
- Confirm
g_published: truein frontmatter - 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:
- Trigger: Tag pushed to GitHub
- Parse: Extract YAML frontmatter
- Convert: Markdown → HTML with markdown-it
- Check: Search for existing post by slug
- Compare: Compare normalized HTML
- Action: Create, update, or skip
References
- Ghost Admin API Documentation
- GitHub Actions Documentation
- Send to Ghost Plugin
- Ghost Writer Manager Plugin
- markdown-it Documentation
Article updated February 15, 2026 - Workflow v1.7.5 with ASCII diagram and smart change detection