The Content Deployment Problem
Most teams manage content and code separately. Developers push to GitHub, which triggers a CI/CD pipeline that builds and deploys the site. Content editors log into the CMS admin panel, write blog posts, and publish them directly. These two workflows have never needed to intersect — until you start treating content as code.
When you treat blog posts as deployable assets — version-controlled, reviewed, tested, and shipped alongside code changes — you unlock a new level of automation. AIKit, combined with GitHub Actions, makes this workflow practical for teams of any size.
Why Automate Content Deployments?
Manual content publishing has hidden costs:
| Factor | Manual | Automated |
|--------|--------|-----------|
| Time per post | 15-30 min SEO + formatting | < 1 min |
| Review cycle | Staged on dev then copy-paste | PR-based |
| Rollback | Manual database restore | Git revert |
| Audit trail | CMS logs only | Git history + PR metadata |
| Consistency | Varies by author | Enforced by pipeline |
For a site publishing 3+ posts per week, the time savings alone justify the automation. The real win, though, is quality and consistency.
The Architecture
AIKit's Auto Blog SEO plugin exposes a content ingestion endpoint that accepts properly formatted JSON. GitHub Actions feeds this endpoint. Here's the flow:
```
[Content author writes markdown]
→ Commits to repo branch
→ GitHub Actions triggers
→ Script parses and validates content
→ Sends to AIKit D1 ingestion API
→ Post appears on site within seconds
→ Plugin runs SEO analysis
→ Post is live, scored, and indexed
```
Setting Up the Pipeline
Step 1: Content Template
Define a standard markdown format with frontmatter:
```markdown
---
title: "Your Blog Post Title"
excerpt: "Short description for SEO"
category: Tutorials
tags: ["AIKit", "EmDash", "CI/CD"]
draft: false
---
Your Heading Here
Content body in markdown. Use ## for section headings.
Include code blocks, lists, and tables as needed.
```
Step 2: GitHub Actions Workflow
Create `.github/workflows/publish-content.yml` in your repo:
```yaml
name: Publish Blog Content
on:
push:
branches: [main]
paths:
- "content/blog/**"
- "content/queue/**"
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Process content files
run: |
for file in $(git diff --name-only HEAD~1 -- content/blog/); do
python3 scripts/process-content.py "$file"
done
- name: Send to AIKit
env:
AIKIT_API_KEY: ${{ secrets.AIKIT_API_KEY }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
run: |
python3 scripts/publish-to-aikit.py
```
Step 3: The Processing Script
The `process-content.py` script converts markdown to the JSON format AIKit expects:
```python
import yaml, json, re, os
def process_content(filepath):
with open(filepath) as f:
content = f.read()
Parse frontmatter
parts = content.split("---", 2)
frontmatter = yaml.safe_load(parts[1])
body = parts[2].strip()
Convert markdown to Portable Text blocks
blocks = []
key_counter = 1
for line in body.split("\n"):
line = line.strip()
if not line:
continue
if line.startswith("## "):
style = "h2"
text = line[3:]
elif line.startswith("### "):
style = "h3"
text = line[4:]
else:
style = "normal"
text = line
blocks.append({
"_type": "block",
"_key": f"b{key_counter}",
"style": style,
"children": [{"_type": "span", "text": text}]
})
key_counter += 1
return {
"title": frontmatter["title"],
"slug": slugify(frontmatter["title"]),
"body_text": body,
"excerpt": frontmatter.get("excerpt", ""),
"category": frontmatter.get("category", "Uncategorized"),
"tags": frontmatter.get("tags", []),
}
```
Handling the Queue Directory
On the AIKit server side, the cron-based queue publisher monitors `~/cmo/content/queue/` for new JSON files:
```bash
Cron job runs every 3 hours
for file in $(ls ~/cmo/content/queue/*.json 2>/dev/null | sort); do
python3 ~/cmo/scripts/blog-publisher.py "$file"
if [ $? -eq 0 ]; then
ts=$(date '+%Y%m%d-%H%M%S')
mv "$file" ~/cmo/content/queue/published/"$ts--$(basename $file)"
fi
done
```
This same pipeline works whether the queue files come from GitHub Actions, a cron job, or manual generation.
Advanced: PR-Based Content Review
For teams that want editorial review baked into the pipeline, use GitHub PRs:
1. Author creates a branch with a markdown file in `content/blog/`
2. The PR triggers a preview action that renders the post on a staging URL
3. Reviewers comment on the PR — feedback stays in GitHub
4. When merged to main, the publish action fires automatically
```yaml
Add to publish-content.yml — preview on PR
on:
pull_request:
paths:
- "content/blog/**"
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build preview
run: |
python3 scripts/build-preview.py
echo "Preview URL: https://preview.aikit.dev/blog/$(slug)"
```
Real Results
Running this pipeline on AIKit's own blog for the past month:
- Average time from commit to published post: 47 seconds
- Zero publishing errors after the first week
- Content team publishes directly from their markdown editor of choice (VS Code, Obsidian, Typora)
- SEO scores improved by 22% because the pipeline auto-runs analysis before publishing
- Developers can revert content changes with a simple `git revert`
The Git-to-blog pipeline has been running for over 80 published posts now. Every single one went through the exact same automated flow — no manual VPS login, no CMS click-and-wait, no copy-paste from Google Docs.
Getting Started
If you want to try this pipeline:
1. Install the AIKit Auto Blog SEO plugin on your EmDash site
2. Create a `content/blog/` directory in your repo
3. Add the GitHub Actions workflow file from above
4. Set your `CLOUDFLARE_ACCOUNT_ID` as a GitHub secret
5. Write your first post and push
That's it. Your next commit can be a blog post.