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:

  1. Publish the day’s article on Medium (English & Spanish).
  2. Send the same copy to Substack newsletters.
  3. 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.
  • writeIfChanged prevents 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-07
  • chore(bluesky): posts 2026-10-07
  • chore(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