Automating Multi‑Channel Blog Publishing with a Content‑Automation Repo
TL;DR: I rewrote the content‑automation pipeline to generate, format, and push daily posts to Medium, Substack, and Bluesky in a single CI step. The change lives in a handful of Markdown, JSON, and metadata files, and it eliminates manual copy‑paste errors while keeping each platform’s payload valid.
The Problem
Every morning I had three tickets in the backlog:
- Publish the day’s article on Medium (English & Spanish).
- Send the same copy to Substack newsletters.
- Post a concise update to Bluesky (English & Spanish).
The manual workflow was:
$ cat content/2026/10/05/content-automation/medium_en.md
# … (copy‑paste) …
I kept opening each file, adjusting front‑matter, fixing line breaks, and then manually uploading via each platform’s UI. The biggest pain point was the payload mismatch for Bluesky – the JSON schema expects a type field (progress/avance) and a text field limited to 300 characters. One typo in the generated JSON caused the API to reject the request with:
Error: payload validation failed – field "text" exceeds max length
The error surfaced only after I had already spent ~30 minutes preparing the Medium draft, so I was forced to roll back changes and re‑edit everything. I needed a deterministic, repeatable process that would:
- Pull the same source Markdown once.
- Render platform‑specific formats (MD → HTML for Medium, plain‑text for Bluesky, newsletter template for Substack).
- Validate each payload before committing.
What I Tried First
My first attempt was a shell script (scripts/publish.sh) that concatenated the Markdown files and called the three platform CLIs:
#!/usr/bin/env bash
mdcat content/2026/10/05/content-automation/medium_en.md | \
medium-cli publish --title "Weekly Review"
I duplicated the same logic for Substack and Bluesky, using jq to build the JSON for Bluesky. The script worked for a day, but it quickly broke:
- Hard‑coded paths – each new date required manual updates.
- No schema validation – the Bluesky JSON was sent raw, so the length error kept slipping through.
- No CI integration – I had to run the script locally, which defeated the “build in public” automation goal.
The failure manifested as a broken JSON payload (missing commas) and a mismatched metadata.json flag (medium_generated: false) that downstream jobs relied on.
The Implementation
1. Repository Layout
content/
└─ 2026/
└─ 10/
└─ 06/
└─ content-automation/
├─ changelog.md
├─ medium_en.md
├─ medium_es.md
├─ substack_en.md
├─ substack_es.md
├─ bluesky_en.json
├─ bluesky_es.json
└─ metadata.json
All files are now generated automatically by a single Node.js script (scripts/generate-content.js). The script reads a template (templates/post.hbs) and injects the day’s data (date, title, tags). It then writes the platform‑specific files and updates metadata.json.
2. Core Generator (scripts/generate-content.js)
// scripts/generate-content.js
const fs = require('fs');
const path = require('path');
const Handlebars = require('handlebars');
// Load shared data
const today = new Date().toISOString().slice(0, 10);
const data = {
date: today,
title: 'Weekly Review – Content Automation',
tags: ['vibecoding', 'buildinpublic', 'automation'],
summary: 'AI‑driven drafts, a ClickUp fix, and multi‑channel publishing.'
};
// Helper to write a file only if content changed
function writeIfChanged(filePath, content) {
if (fs.existsSync(filePath) && fs.readFileSync(filePath, 'utf8') === content) return;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
}
// 1️⃣ Generate Medium Markdown (EN & ES)
['en', 'es'].forEach(lang => {
const tmpl = fs.readFileSync(`templates/medium_${lang}.hbs`, 'utf8');
const compiled = Handlebars.compile(tmpl);
const md = compiled(data);
writeIfChanged(`content/2026/10/06/content-automation/medium_${lang}.md`, md);
});
// 2️⃣ Generate Substack newsletters
['en', 'es'].forEach(lang => {
const tmpl = fs.readFileSync(`templates/substack_${lang}.hbs`, 'utf8');
const compiled = Handlebars.compile(tmpl);
const md = compiled(data);
writeIfChanged(`content/2026/10/06/content-automation/substack_${lang}.md`, md);
});
// 3️⃣ Generate Bluesky JSON payloads
['en', 'es'].forEach(lang => {
const payload = {
type: lang === 'en' ? 'progress' : 'avance',
text: `${data.title} – ${data.summary}`.slice(0, 300) // enforce limit
};
writeIfChanged(
`content/2026/10/06/content-automation/bluesky_${lang}.json`,
JSON.stringify([payload], null, 2)
);
});
// 4️⃣ Update metadata flags
const metaPath = 'content/2026/10/06/content-automation/metadata.json';
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
meta.medium_generated = true;
meta.substack_generated = true;
meta.bluesky_generated = true;
writeIfChanged(metaPath, JSON.stringify(meta, null, 2));
console.log('✅ Content generation complete');
Key points
- Handlebars templates keep the prose DRY. Only the language‑specific strings differ.
-
writeIfChangedprevents unnecessary Git diffs, which keeps the CI cache happy. - The Bluesky payload is built with a hard limit (
slice(0, 300)) to guarantee schema compliance before the API call.
3. CI Integration
The new GitHub Actions workflow (.github/workflows/content.yml) runs on a schedule (cron: '0 6 * * *') and on any push to main:
name: Content Automation
on:
schedule:
- cron: '0 6 * * *' # 6 AM UTC daily
push:
paths:
- 'content/**'
- 'scripts/**'
- 'templates/**'
jobs:
generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Node
uses: actions/setup-node@v3
with:
node-version: '20'
- run: npm ci
- run: node scripts/generate-content.js
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: "chore(content): auto-generate $(date +%Y-%m-%d) [skip ci]"
file_pattern: "content/**"
The workflow auto‑commits the generated files, which is exactly the three commits we see in the diff:
chore(content): auto-generate 2026-10-07chore(bluesky): posts 2026-10-07chore(devto): article 2026-10-07
Notice how the metadata.json flags are now set to true after generation:
- "medium_generated": false,
- "substack_generated": false,
+ "medium_generated": true,
+ "substack_generated": true,
4
Part of my Build in Public series — sharing the real process of building SaaS projects from Playa del Carmen, México.
Repo: zaerohell/content-automation · 2026-10-07
#playadev #buildinpublic
Top comments (0)